blob: fedf50392bd7ce73a2a7c121af9014d0e2a81650 [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.recyclerview;
import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId;
import android.content.res.Resources;
import android.os.Handler;
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.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);
}
/**
* 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();
}
};
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();
if (getRecyclerView().getLayoutManager() == null
|| getRecyclerView().getChildCount() == 0
|| currentOffset == 0) {
return;
}
// Use OrientationHelper to calculate scroll distance in order to match snapping behavior.
OrientationHelper orientationHelper =
getOrientationHelper(getRecyclerView().getLayoutManager());
int screenSize = orientationHelper.getTotalSpace();
int scrollDistance = screenSize;
// The iteration order matters. In case where there are 2 items longer than screen size, we
// want to focus on upcoming view.
for (int i = 0; i < getRecyclerView().getChildCount(); i++) {
/*
* We treat child View longer than screen size differently:
* 1) When it enters screen, next pageUp will align its bottom with parent bottom;
* 2) When it leaves screen, next pageUp will align its top with parent top.
*/
View child = getRecyclerView().getChildAt(i);
if (child.getHeight() > screenSize) {
if (orientationHelper.getDecoratedEnd(child) < screenSize) {
// Child view bottom is entering screen. Align its bottom with parent bottom.
scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child);
} else if (-screenSize < orientationHelper.getDecoratedStart(child)
&& orientationHelper.getDecoratedStart(child) < 0) {
// Child view top is about to enter screen - its distance to parent top
// is less than a full scroll. Align child top with parent top.
scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child));
}
// There can be two items that are longer than the screen. We stop at the first one.
// This is affected by the iteration order.
break;
}
}
// Distance should always be positive. Negate its value to scroll up.
mRecyclerView.smoothScrollBy(0, -scrollDistance);
}
/**
* 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() {
if (getRecyclerView().getLayoutManager() == null
|| getRecyclerView().getChildCount() == 0) {
return;
}
OrientationHelper orientationHelper =
getOrientationHelper(getRecyclerView().getLayoutManager());
int screenSize = orientationHelper.getTotalSpace();
int scrollDistance = screenSize;
// If the last item is partially visible, page down should bring it to the top.
View lastChild = getRecyclerView().getChildAt(getRecyclerView().getChildCount() - 1);
if (getRecyclerView().getLayoutManager().isViewPartiallyVisible(lastChild,
/* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) {
scrollDistance = orientationHelper.getDecoratedStart(lastChild)
- orientationHelper.getStartAfterPadding();
if (scrollDistance <= 0) {
// - Scroll value is zero if the top of last item is aligned with top of the screen;
// - Scroll value can be negative if the child is longer than the screen size and
// the visible area of the screen does not show the start of the child.
// Scroll to the next screen in both cases.
scrollDistance = screenSize;
}
}
// The iteration order matters. In case where there are 2 items longer than screen size, we
// want to focus on upcoming view (the one at the bottom of screen).
for (int i = getRecyclerView().getChildCount() - 1; i >= 0; i--) {
/* We treat child View longer than screen size differently:
* 1) When it enters screen, next pageDown will align its top with parent top;
* 2) When it leaves screen, next pageDown will align its bottom with parent bottom.
*/
View child = getRecyclerView().getChildAt(i);
if (child.getHeight() > screenSize) {
if (orientationHelper.getDecoratedStart(child)
- orientationHelper.getStartAfterPadding() > 0) {
// Child view top is entering screen. Align its top with parent top.
scrollDistance = orientationHelper.getDecoratedStart(lastChild)
- orientationHelper.getStartAfterPadding();
} else if (screenSize < orientationHelper.getDecoratedEnd(child)
&& orientationHelper.getDecoratedEnd(child) < 2 * screenSize) {
// Child view bottom is about to enter screen - its distance to parent bottom
// is less than a full scroll. Align child bottom with parent bottom.
scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize;
}
// There can be two items that are longer than the screen. We stop at the first one.
// This is affected by the iteration order.
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());
}
}