blob: bdfc6504e3b9d32bf17a18a69d32931f1fe5223d [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.pagedrecyclerview;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.car.drivingstate.CarUxRestrictions;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.android.car.ui.R;
import com.android.car.ui.pagedrecyclerview.decorations.grid.GridDividerItemDecoration;
import com.android.car.ui.pagedrecyclerview.decorations.grid.GridOffsetItemDecoration;
import com.android.car.ui.pagedrecyclerview.decorations.linear.LinearDividerItemDecoration;
import com.android.car.ui.pagedrecyclerview.decorations.linear.LinearOffsetItemDecoration;
import com.android.car.ui.pagedrecyclerview.decorations.linear.LinearOffsetItemDecoration.OffsetPosition;
import java.lang.annotation.Retention;
/**
* View that extends a {@link RecyclerView} and creates a nested {@code RecyclerView} which could
* potentially include a scrollbar that has page up and down arrows. Interaction with this view is
* similar to a {@code RecyclerView} as it takes the same adapter and the layout manager.
*/
public final class PagedRecyclerView extends RecyclerView {
private static final boolean DEBUG = false;
private static final String TAG = "PagedRecyclerView";
private final CarUxRestrictionsUtil mCarUxRestrictionsUtil;
private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener;
private boolean mScrollBarEnabled;
private int mScrollBarContainerWidth;
@ScrollBarPosition
private int mScrollBarPosition;
private boolean mScrollBarAboveRecyclerView;
private String mScrollBarClass;
private boolean mFullyInitialized;
private float mScrollBarPaddingStart;
private float mScrollBarPaddingEnd;
private Context mContext;
@Gutter
private int mGutter;
private int mGutterSize;
private RecyclerView mNestedRecyclerView;
private Adapter<?> mAdapter;
private ScrollBar mScrollBar;
private GridOffsetItemDecoration mOffsetItemDecoration;
private GridDividerItemDecoration mDividerItemDecoration;
@PagedRecyclerViewLayout
int mPagedRecyclerViewLayout;
private int mNumOfColumns;
/**
* The possible values for @{link #setGutter}. The default value is actually {@link
* PagedRecyclerView.Gutter#BOTH}.
*/
@IntDef({
Gutter.NONE,
Gutter.START,
Gutter.END,
Gutter.BOTH,
})
@Retention(SOURCE)
public @interface Gutter {
/**
* No gutter on either side of the list items. The items will span the full width of the
* RecyclerView
*/
int NONE = 0;
/** Include a gutter only on the start side (that is, the same side as the scroll bar). */
int START = 1;
/** Include a gutter only on the end side (that is, the opposite side of the scroll bar). */
int END = 2;
/** Include a gutter on both sides of the list items. This is the default behaviour. */
int BOTH = 3;
}
/**
* The possible values for setScrollbarPosition. The default value is actually {@link
* PagedRecyclerView.ScrollBarPosition#START}.
*/
@IntDef({
ScrollBarPosition.START,
ScrollBarPosition.END,
})
@Retention(SOURCE)
public @interface ScrollBarPosition {
/** Position the scrollbar to the left of the screen. This is default. */
int START = 0;
/** Position scrollbar to the right of the screen. */
int END = 2;
}
/**
* The possible values for setScrollbarPosition. The default value is actually {@link
* PagedRecyclerViewLayout#LINEAR}.
*/
@IntDef({
PagedRecyclerViewLayout.LINEAR,
PagedRecyclerViewLayout.GRID,
})
@Retention(SOURCE)
public @interface PagedRecyclerViewLayout {
/** Position the scrollbar to the left of the screen. This is default. */
int LINEAR = 0;
/** Position scrollbar to the right of the screen. */
int GRID = 2;
}
/**
* Interface for a {@link RecyclerView.Adapter} to cap the number of items.
*
* <p>NOTE: it is still up to the adapter to use maxItems in {@link
* RecyclerView.Adapter#getItemCount()}.
*
* <p>the recommended way would be with:
*
* <pre>{@code
* {@literal@}Override
* public int getItemCount() {
* return Math.min(super.getItemCount(), mMaxItems);
* }
* }</pre>
*/
public interface ItemCap {
/** A value to pass to {@link #setMaxItems(int)} that indicates there should be no limit. */
int UNLIMITED = -1;
/**
* Sets the maximum number of items available in the adapter. A value less than '0' means
* the
* list should not be capped.
*/
void setMaxItems(int maxItems);
}
/**
* Custom layout manager for the outer recyclerview. Since paddings should be applied by the
* inner
* recycler view within its bounds, this layout manager should always have 0 padding.
*/
private static class PagedRecyclerViewLayoutManager extends LinearLayoutManager {
PagedRecyclerViewLayoutManager(Context context) {
super(context);
}
@Override
public int getPaddingTop() {
return 0;
}
@Override
public int getPaddingBottom() {
return 0;
}
@Override
public int getPaddingStart() {
return 0;
}
@Override
public int getPaddingEnd() {
return 0;
}
@Override
public boolean canScrollHorizontally() {
return false;
}
@Override
public boolean canScrollVertically() {
return false;
}
}
/**
* Custom layout manager for the outer recyclerview. Since paddings should be applied by the
* inner
* recycler view within its bounds, this layout manager should always have 0 padding.
*/
private static class GridPagedRecyclerViewLayoutManager extends GridLayoutManager {
GridPagedRecyclerViewLayoutManager(Context context, int numOfColumns) {
super(context, numOfColumns);
}
@Override
public int getPaddingTop() {
return 0;
}
@Override
public int getPaddingBottom() {
return 0;
}
@Override
public int getPaddingStart() {
return 0;
}
@Override
public int getPaddingEnd() {
return 0;
}
}
public PagedRecyclerView(@NonNull Context context) {
this(context, null, 0);
}
public PagedRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PagedRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(context);
mListener = this::updateCarUxRestrictions;
init(context, attrs, defStyle);
}
private void init(Context context, AttributeSet attrs, int defStyleAttr) {
TypedArray a =
context.obtainStyledAttributes(
attrs, R.styleable.PagedRecyclerView, defStyleAttr,
R.style.Widget_Chassis_PagedRecyclerView);
mScrollBarEnabled = context.getResources().getBoolean(R.bool.chassis_scrollbar_enable);
mFullyInitialized = false;
if (!mScrollBarEnabled) {
a.recycle();
mFullyInitialized = true;
return;
}
mNestedRecyclerView =
new RecyclerView(context, attrs,
R.style.Widget_Chassis_PagedRecyclerView_NestedRecyclerView);
mScrollBarPaddingStart =
context.getResources().getDimension(R.dimen.chassis_scrollbar_padding_start);
mScrollBarPaddingEnd =
context.getResources().getDimension(R.dimen.chassis_scrollbar_padding_end);
mPagedRecyclerViewLayout =
a.getInt(R.styleable.PagedRecyclerView_layoutStyle, PagedRecyclerViewLayout.LINEAR);
mNumOfColumns = a.getInt(R.styleable.PagedRecyclerView_numOfColumns, /* defValue= */ 2);
boolean enableDivider =
a.getBoolean(R.styleable.PagedRecyclerView_enableDivider, /* defValue= */ true);
if (mPagedRecyclerViewLayout == PagedRecyclerViewLayout.LINEAR) {
int linearTopOffset =
a.getInteger(R.styleable.PagedRecyclerView_startOffset, /* defValue= */ 0);
int linearBottomOffset =
a.getInteger(R.styleable.PagedRecyclerView_endOffset, /* defValue= */ 0);
if (enableDivider) {
RecyclerView.ItemDecoration dividerItemDecoration =
new LinearDividerItemDecoration(
context.getDrawable(R.drawable.chassis_pagedrecyclerview_divider));
super.addItemDecoration(dividerItemDecoration);
}
RecyclerView.ItemDecoration topOffsetItemDecoration =
new LinearOffsetItemDecoration(linearTopOffset, OffsetPosition.START);
super.addItemDecoration(topOffsetItemDecoration);
RecyclerView.ItemDecoration bottomOffsetItemDecoration =
new LinearOffsetItemDecoration(linearBottomOffset, OffsetPosition.END);
super.addItemDecoration(bottomOffsetItemDecoration);
} else {
int gridTopOffset =
a.getInteger(R.styleable.PagedRecyclerView_startOffset, /* defValue= */ 0);
int gridBottomOffset =
a.getInteger(R.styleable.PagedRecyclerView_endOffset, /* defValue= */ 0);
if (enableDivider) {
mDividerItemDecoration =
new GridDividerItemDecoration(
context.getDrawable(R.drawable.chassis_divider),
context.getDrawable(R.drawable.chassis_divider),
mNumOfColumns);
super.addItemDecoration(mDividerItemDecoration);
}
mOffsetItemDecoration =
new GridOffsetItemDecoration(gridTopOffset, mNumOfColumns,
OffsetPosition.START);
super.addItemDecoration(mOffsetItemDecoration);
GridOffsetItemDecoration bottomOffsetItemDecoration =
new GridOffsetItemDecoration(gridBottomOffset, mNumOfColumns,
OffsetPosition.END);
super.addItemDecoration(bottomOffsetItemDecoration);
}
super.setLayoutManager(new PagedRecyclerViewLayoutManager(context));
super.setAdapter(new PagedRecyclerViewAdapter());
super.setNestedScrollingEnabled(false);
super.setClipToPadding(false);
// Gutter
mGutter = context.getResources().getInteger(R.integer.chassis_scrollbar_gutter);
mGutterSize = getResources().getDimensionPixelSize(R.dimen.chassis_scrollbar_margin);
mScrollBarContainerWidth =
(int) context.getResources().getDimension(
R.dimen.chassis_scrollbar_container_width);
mScrollBarPosition = context.getResources().getInteger(
R.integer.chassis_scrollbar_position);
mScrollBarAboveRecyclerView =
context.getResources().getBoolean(R.bool.chassis_scrollbar_above_recycler_view);
mScrollBarClass = context.getResources().getString(R.string.chassis_scrollbar_component);
a.recycle();
this.mContext = context;
// Apply inner RV layout changes after the layout has been calculated for this view.
this.getViewTreeObserver()
.addOnGlobalLayoutListener(
new OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// View holder layout is still pending.
if (PagedRecyclerView.this.findViewHolderForAdapterPosition(0)
== null) {
return;
}
PagedRecyclerView.this.getViewTreeObserver()
.removeOnGlobalLayoutListener(this);
initNestedRecyclerView();
setNestedViewLayout();
mNestedRecyclerView
.getViewTreeObserver()
.addOnGlobalLayoutListener(
new OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
mNestedRecyclerView
.getViewTreeObserver()
.removeOnGlobalLayoutListener(this);
ViewGroup.LayoutParams params =
getLayoutParams();
params.height = getMeasuredHeight();
setLayoutParams(params);
createScrollBarFromConfig();
mFullyInitialized = true;
}
});
}
});
}
/**
* Returns {@code true} if the {@PagedRecyclerView} is fully drawn. Using a global layout
* mListener
* may not necessarily signify that this view is fully drawn (i.e. when the scrollbar is
* enabled).
* This is because the inner views (scrollbar and inner recycler view) are drawn after the
* outer
* views are finished.
*/
public boolean fullyInitialized() {
return mFullyInitialized;
}
/** Sets the number of columns in which grid needs to be divided. */
public void setNumOfColumns(int numberOfColumns) {
mNumOfColumns = numberOfColumns;
mOffsetItemDecoration.setNumOfColumns(mNumOfColumns);
mDividerItemDecoration.setNumOfColumns(mNumOfColumns);
}
/**
* Returns the {@link LayoutManager} for the {@link RecyclerView} displaying the content.
*
* <p>In cases where the scroll bar is visible and the nested {@link RecyclerView} is displaying
* content, {@link #getLayoutManager()} cannot be used because it returns the {@link
* LayoutManager} of the outer {@link RecyclerView}. {@link #getLayoutManager()} could not be
* overridden to return the effective manager due to interference with accessibility node tree
* traversal.
*/
@Nullable
public LayoutManager getEffectiveLayoutManager() {
if (mScrollBarEnabled) {
return mNestedRecyclerView.getLayoutManager();
}
return super.getLayoutManager();
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mCarUxRestrictionsUtil.register(mListener);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mCarUxRestrictionsUtil.unregister(mListener);
}
private void updateCarUxRestrictions(CarUxRestrictions carUxRestrictions) {
// If the adapter does not implement ItemCap, then the max items on it cannot be updated.
if (!(mAdapter instanceof ItemCap)) {
return;
}
int maxItems = ItemCap.UNLIMITED;
if ((carUxRestrictions.getActiveRestrictions()
& CarUxRestrictions.UX_RESTRICTIONS_LIMIT_CONTENT)
!= 0) {
maxItems = carUxRestrictions.getMaxCumulativeContentItems();
}
int originalCount = mAdapter.getItemCount();
((ItemCap) mAdapter).setMaxItems(maxItems);
int newCount = mAdapter.getItemCount();
if (newCount == originalCount) {
return;
}
if (newCount < originalCount) {
mAdapter.notifyItemRangeRemoved(newCount, originalCount - newCount);
} else {
mAdapter.notifyItemRangeInserted(originalCount, newCount - originalCount);
}
}
@Override
public void setClipToPadding(boolean clipToPadding) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setClipToPadding(clipToPadding);
} else {
super.setClipToPadding(clipToPadding);
}
}
@SuppressWarnings("rawtypes")
@Override
public void setAdapter(@Nullable Adapter adapter) {
if (mPagedRecyclerViewLayout == PagedRecyclerViewLayout.LINEAR) {
mNestedRecyclerView.setLayoutManager(new LinearLayoutManager(mContext));
} else {
mNestedRecyclerView.setLayoutManager(
new GridPagedRecyclerViewLayoutManager(mContext, mNumOfColumns));
setNumOfColumns(mNumOfColumns);
}
this.mAdapter = adapter;
if (mScrollBarEnabled) {
mNestedRecyclerView.setAdapter(adapter);
} else {
super.setAdapter(adapter);
}
}
@Nullable
@Override
public Adapter<?> getAdapter() {
if (mScrollBarEnabled) {
return mNestedRecyclerView.getAdapter();
}
return super.getAdapter();
}
@Override
public void setLayoutManager(@Nullable LayoutManager layout) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setLayoutManager(layout);
} else {
super.setLayoutManager(layout);
}
}
@Override
public void setOnScrollChangeListener(OnScrollChangeListener l) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setOnScrollChangeListener(l);
} else {
super.setOnScrollChangeListener(l);
}
}
@Override
public void setVerticalFadingEdgeEnabled(boolean verticalFadingEdgeEnabled) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setVerticalFadingEdgeEnabled(verticalFadingEdgeEnabled);
} else {
super.setVerticalFadingEdgeEnabled(verticalFadingEdgeEnabled);
}
}
@Override
public void setFadingEdgeLength(int length) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setFadingEdgeLength(length);
} else {
super.setFadingEdgeLength(length);
}
}
@Override
public void addItemDecoration(@NonNull ItemDecoration decor, int index) {
if (mScrollBarEnabled) {
mNestedRecyclerView.addItemDecoration(decor, index);
} else {
super.addItemDecoration(decor, index);
}
}
@Override
public void addItemDecoration(@NonNull ItemDecoration decor) {
if (mScrollBarEnabled) {
mNestedRecyclerView.addItemDecoration(decor);
} else {
super.addItemDecoration(decor);
}
}
@Override
public void setItemAnimator(@Nullable ItemAnimator animator) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setItemAnimator(animator);
} else {
super.setItemAnimator(animator);
}
}
@Override
public void setPadding(int left, int top, int right, int bottom) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setPadding(left, top, right, bottom);
if (mScrollBar != null) {
mScrollBar.requestLayout();
}
} else {
super.setPadding(left, top, right, bottom);
}
}
@Override
public void setPaddingRelative(int start, int top, int end, int bottom) {
if (mScrollBarEnabled) {
mNestedRecyclerView.setPaddingRelative(start, top, end, bottom);
if (mScrollBar != null) {
mScrollBar.requestLayout();
}
} else {
super.setPaddingRelative(start, top, end, bottom);
}
}
@Override
public ViewHolder findViewHolderForLayoutPosition(int position) {
if (mScrollBarEnabled) {
return mNestedRecyclerView.findViewHolderForLayoutPosition(position);
} else {
return super.findViewHolderForLayoutPosition(position);
}
}
@Override
public ViewHolder findContainingViewHolder(View view) {
if (mScrollBarEnabled) {
return mNestedRecyclerView.findContainingViewHolder(view);
} else {
return super.findContainingViewHolder(view);
}
}
@Override
@Nullable
public View findChildViewUnder(float x, float y) {
if (mScrollBarEnabled) {
return mNestedRecyclerView.findChildViewUnder(x, y);
} else {
return super.findChildViewUnder(x, y);
}
}
@Override
public void addOnScrollListener(@NonNull OnScrollListener listener) {
if (mScrollBarEnabled) {
mNestedRecyclerView.addOnScrollListener(listener);
} else {
super.addOnScrollListener(listener);
}
}
@Override
public void removeOnScrollListener(@NonNull OnScrollListener listener) {
if (mScrollBarEnabled) {
mNestedRecyclerView.removeOnScrollListener(listener);
} else {
super.removeOnScrollListener(listener);
}
}
@Override
public int getPaddingStart() {
return mScrollBarEnabled ? mNestedRecyclerView.getPaddingStart() : super.getPaddingStart();
}
@Override
public int getPaddingEnd() {
return mScrollBarEnabled ? mNestedRecyclerView.getPaddingEnd() : super.getPaddingEnd();
}
@Override
public int getPaddingTop() {
return mScrollBarEnabled ? mNestedRecyclerView.getPaddingTop() : super.getPaddingTop();
}
@Override
public int getPaddingBottom() {
return mScrollBarEnabled ? mNestedRecyclerView.getPaddingBottom()
: super.getPaddingBottom();
}
@Override
public void setVisibility(int visibility) {
super.setVisibility(visibility);
if (mScrollBarEnabled) {
mNestedRecyclerView.setVisibility(visibility);
}
}
private void initNestedRecyclerView() {
PagedRecyclerViewAdapter.NestedRowViewHolder vh =
(PagedRecyclerViewAdapter.NestedRowViewHolder)
this.findViewHolderForAdapterPosition(0);
if (vh == null) {
throw new Error("Outer RecyclerView failed to initialize.");
}
vh.frameLayout.addView(mNestedRecyclerView);
}
private void createScrollBarFromConfig() {
if (DEBUG) {
Log.d(TAG, "createScrollBarFromConfig");
}
Class<?> cls;
try {
cls = !TextUtils.isEmpty(mScrollBarClass)
? getContext().getClassLoader().loadClass(mScrollBarClass)
: DefaultScrollBar.class;
} catch (Throwable t) {
throw andLog("Error loading scroll bar component: " + mScrollBarClass, t);
}
try {
mScrollBar = (ScrollBar) cls.getDeclaredConstructor().newInstance();
} catch (Throwable t) {
throw andLog("Error creating scroll bar component: " + mScrollBarClass, t);
}
mScrollBar.initialize(
mNestedRecyclerView, mScrollBarContainerWidth, mScrollBarPosition,
mScrollBarAboveRecyclerView);
mScrollBar.setPadding((int) mScrollBarPaddingStart, (int) mScrollBarPaddingEnd);
if (DEBUG) {
Log.d(TAG, "started " + mScrollBar.getClass().getSimpleName());
}
}
/**
* Set the nested view's layout to the specified value.
*
* <p>The mGutter is the space to the start/end of the list view items and will be equal in size
* to
* the scroll bars. By default, there is a mGutter to both the left and right of the list view
* items, to account for the scroll bar.
*/
private void setNestedViewLayout() {
int startMargin = 0;
int endMargin = 0;
if ((mGutter & Gutter.START) != 0) {
startMargin = mGutterSize;
}
if ((mGutter & Gutter.END) != 0) {
endMargin = mGutterSize;
}
MarginLayoutParams layoutParams =
(MarginLayoutParams) mNestedRecyclerView.getLayoutParams();
layoutParams.setMarginStart(startMargin);
layoutParams.setMarginEnd(endMargin);
layoutParams.height = LayoutParams.MATCH_PARENT;
layoutParams.width = super.getLayoutManager().getWidth() - startMargin - endMargin;
// requestLayout() isn't sufficient because we also need to resolveLayoutParams().
mNestedRecyclerView.setLayoutParams(layoutParams);
// If there's a mGutter, set ClipToPadding to false so that CardView's shadow will still
// appear outside of the padding.
mNestedRecyclerView.setClipToPadding(startMargin == 0 && endMargin == 0);
}
private static RuntimeException andLog(String msg, Throwable t) {
Log.e(TAG, msg, t);
throw new RuntimeException(msg, t);
}
@Override
public Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState, getContext());
if (mScrollBarEnabled) {
mNestedRecyclerView.saveHierarchyState(ss.mNestedRecyclerViewState);
}
return ss;
}
@Override
public void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof SavedState)) {
Log.w(TAG, "onRestoreInstanceState called with an unsupported state");
super.onRestoreInstanceState(state);
} else {
SavedState ss = (SavedState) state;
super.onRestoreInstanceState(ss.getSuperState());
if (mScrollBarEnabled) {
mNestedRecyclerView.restoreHierarchyState(ss.mNestedRecyclerViewState);
}
}
}
static class SavedState extends BaseSavedState {
SparseArray<Parcelable> mNestedRecyclerViewState;
Context mContext;
SavedState(Parcelable superState, Context c) {
super(superState);
mContext = c;
mNestedRecyclerViewState = new SparseArray<>();
}
@SuppressWarnings("unchecked")
private SavedState(Parcel in) {
super(in);
mNestedRecyclerViewState = in.readSparseArray(mContext.getClassLoader());
}
@SuppressWarnings("unchecked")
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeSparseArray((SparseArray<Object>) (Object) mNestedRecyclerViewState);
}
public static final Parcelable.Creator<SavedState> CREATOR =
new Parcelable.Creator<SavedState>() {
@Override
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
@Override
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
}