blob: 93b77365359ebd9e0d1e24c69696fc5004bc4243 [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.findViewByRefId;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.car.drivingstate.CarUxRestrictions;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Rect;
import android.os.Parcelable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.InputDevice;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewPropertyAnimator;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
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.recyclerview.decorations.grid.GridDividerItemDecoration;
import com.android.car.ui.recyclerview.decorations.grid.GridOffsetItemDecoration;
import com.android.car.ui.recyclerview.decorations.linear.LinearDividerItemDecoration;
import com.android.car.ui.recyclerview.decorations.linear.LinearOffsetItemDecoration;
import com.android.car.ui.recyclerview.decorations.linear.LinearOffsetItemDecoration.OffsetPosition;
import com.android.car.ui.utils.CarUiUtils;
import com.android.car.ui.utils.CarUxRestrictionsUtil;
import java.lang.annotation.Retention;
import java.util.Objects;
/**
* View that extends a {@link RecyclerView} and wraps itself into a {@link LinearLayout} 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 CarUiRecyclerView extends RecyclerView {
private static final String TAG = "CarUiRecyclerView";
private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener =
new UxRestrictionChangedListener();
@NonNull
private final CarUxRestrictionsUtil mCarUxRestrictionsUtil;
private boolean mScrollBarEnabled;
@Nullable
private String mScrollBarClass;
private int mScrollBarPaddingTop;
private int mScrollBarPaddingBottom;
@Nullable
private ScrollBar mScrollBar;
@Nullable
private GridOffsetItemDecoration mTopOffsetItemDecorationGrid;
@Nullable
private GridOffsetItemDecoration mBottomOffsetItemDecorationGrid;
@Nullable
private RecyclerView.ItemDecoration mTopOffsetItemDecorationLinear;
@Nullable
private RecyclerView.ItemDecoration mBottomOffsetItemDecorationLinear;
@Nullable
private GridDividerItemDecoration mDividerItemDecorationGrid;
@Nullable
private RecyclerView.ItemDecoration mDividerItemDecorationLinear;
private int mNumOfColumns;
private boolean mInstallingExtScrollBar = false;
private int mContainerVisibility = View.VISIBLE;
@Nullable
private Rect mContainerPadding;
@Nullable
private Rect mContainerPaddingRelative;
@Nullable
private LinearLayout mContainer;
// Set to true when when styled attributes are read and initialized.
private boolean mIsInitialized;
private boolean mEnableDividers;
private int mTopOffset;
private int mBottomOffset;
private boolean mHasScrolled = false;
private OnScrollListener mOnScrollListener = new OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dx > 0 || dy > 0) {
mHasScrolled = true;
removeOnScrollListener(this);
}
}
};
/**
* The possible values for setScrollBarPosition. The default value is actually {@link
* CarUiRecyclerViewLayout#LINEAR}.
*/
@IntDef({
CarUiRecyclerViewLayout.LINEAR,
CarUiRecyclerViewLayout.GRID,
})
@Retention(SOURCE)
public @interface CarUiRecyclerViewLayout {
/**
* Arranges items either horizontally in a single row or vertically in a single column. This
* is default.
*/
int LINEAR = 0;
/**
* Arranges items in a Grid.
*/
int GRID = 1;
}
/**
* 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);
}
public CarUiRecyclerView(@NonNull Context context) {
this(context, null);
}
public CarUiRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.carUiRecyclerViewStyle);
}
public CarUiRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(context);
init(context, attrs, defStyle);
}
private void init(Context context, AttributeSet attrs, int defStyleAttr) {
setClipToPadding(false);
TypedArray a = context.obtainStyledAttributes(
attrs,
R.styleable.CarUiRecyclerView,
defStyleAttr,
R.style.Widget_CarUi_CarUiRecyclerView);
initRotaryScroll(a);
mScrollBarEnabled = context.getResources().getBoolean(R.bool.car_ui_scrollbar_enable);
mScrollBarPaddingTop = context.getResources()
.getDimensionPixelSize(R.dimen.car_ui_scrollbar_padding_top);
mScrollBarPaddingBottom = context.getResources()
.getDimensionPixelSize(R.dimen.car_ui_scrollbar_padding_bottom);
@CarUiRecyclerViewLayout int carUiRecyclerViewLayout =
a.getInt(R.styleable.CarUiRecyclerView_layoutStyle, CarUiRecyclerViewLayout.LINEAR);
mNumOfColumns = a.getInt(R.styleable.CarUiRecyclerView_numOfColumns, /* defValue= */ 2);
mEnableDividers =
a.getBoolean(R.styleable.CarUiRecyclerView_enableDivider, /* defValue= */ false);
mDividerItemDecorationLinear = new LinearDividerItemDecoration(
context.getDrawable(R.drawable.car_ui_recyclerview_divider));
mDividerItemDecorationGrid =
new GridDividerItemDecoration(
context.getDrawable(R.drawable.car_ui_divider),
context.getDrawable(R.drawable.car_ui_divider),
mNumOfColumns);
mTopOffset = a.getInteger(R.styleable.CarUiRecyclerView_topOffset, /* defValue= */0);
mBottomOffset = a.getInteger(
R.styleable.CarUiRecyclerView_bottomOffset, /* defValue= */0);
mTopOffsetItemDecorationLinear =
new LinearOffsetItemDecoration(mTopOffset, OffsetPosition.START);
mBottomOffsetItemDecorationLinear =
new LinearOffsetItemDecoration(mBottomOffset, OffsetPosition.END);
mTopOffsetItemDecorationGrid =
new GridOffsetItemDecoration(mTopOffset, mNumOfColumns,
OffsetPosition.START);
mBottomOffsetItemDecorationGrid =
new GridOffsetItemDecoration(mBottomOffset, mNumOfColumns,
OffsetPosition.END);
mIsInitialized = true;
// Check if a layout manager has already been set via XML
boolean isLayoutMangerSet = getLayoutManager() != null;
if (!isLayoutMangerSet && carUiRecyclerViewLayout == CarUiRecyclerViewLayout.LINEAR) {
setLayoutManager(new LinearLayoutManager(getContext()));
} else if (!isLayoutMangerSet && carUiRecyclerViewLayout == CarUiRecyclerViewLayout.GRID) {
setLayoutManager(new GridLayoutManager(getContext(), mNumOfColumns));
}
addOnScrollListener(mOnScrollListener);
a.recycle();
if (!mScrollBarEnabled) {
return;
}
mContainer = new LinearLayout(getContext());
setVerticalScrollBarEnabled(false);
setHorizontalScrollBarEnabled(false);
mScrollBarClass = context.getResources().getString(R.string.car_ui_scrollbar_component);
}
@Override
public void setLayoutManager(@Nullable LayoutManager layoutManager) {
// Cannot setup item decorations before stylized attributes have been read.
if (mIsInitialized) {
addItemDecorations(layoutManager);
}
super.setLayoutManager(layoutManager);
}
// This method should not be invoked before item decorations are initialized by the #init()
// method.
private void addItemDecorations(LayoutManager layoutManager) {
// remove existing Item decorations.
removeItemDecoration(Objects.requireNonNull(mDividerItemDecorationGrid));
removeItemDecoration(Objects.requireNonNull(mTopOffsetItemDecorationGrid));
removeItemDecoration(Objects.requireNonNull(mBottomOffsetItemDecorationGrid));
removeItemDecoration(Objects.requireNonNull(mDividerItemDecorationLinear));
removeItemDecoration(Objects.requireNonNull(mTopOffsetItemDecorationLinear));
removeItemDecoration(Objects.requireNonNull(mBottomOffsetItemDecorationLinear));
if (layoutManager instanceof GridLayoutManager) {
if (mEnableDividers) {
addItemDecoration(Objects.requireNonNull(mDividerItemDecorationGrid));
}
addItemDecoration(Objects.requireNonNull(mTopOffsetItemDecorationGrid));
addItemDecoration(Objects.requireNonNull(mBottomOffsetItemDecorationGrid));
setNumOfColumns(((GridLayoutManager) layoutManager).getSpanCount());
} else {
if (mEnableDividers) {
addItemDecoration(Objects.requireNonNull(mDividerItemDecorationLinear));
}
addItemDecoration(Objects.requireNonNull(mTopOffsetItemDecorationLinear));
addItemDecoration(Objects.requireNonNull(mBottomOffsetItemDecorationLinear));
}
}
/**
* If this view's {@code rotaryScrollEnabled} attribute is set to true, sets the content
* description so that the {@code RotaryService} will treat it as a scrollable container and
* initializes this view accordingly.
*/
private void initRotaryScroll(@Nullable TypedArray styledAttributes) {
boolean rotaryScrollEnabled = styledAttributes != null && styledAttributes.getBoolean(
R.styleable.CarUiRecyclerView_rotaryScrollEnabled, /* defValue=*/ false);
if (rotaryScrollEnabled) {
int orientation = styledAttributes.getInt(R.styleable.RecyclerView_android_orientation,
LinearLayout.VERTICAL);
CarUiUtils.setRotaryScrollEnabled(
this, /* isVertical= */ orientation == LinearLayout.VERTICAL);
} else {
CharSequence contentDescription = getContentDescription();
rotaryScrollEnabled = contentDescription != null
&& (ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription)
|| ROTARY_VERTICALLY_SCROLLABLE.contentEquals(contentDescription));
}
// If rotary scrolling is enabled, set a generic motion event listener to convert
// SOURCE_ROTARY_ENCODER scroll events into SOURCE_MOUSE scroll events that RecyclerView
// knows how to handle.
setOnGenericMotionListener(rotaryScrollEnabled ? (v, event) -> {
if (event.getAction() == MotionEvent.ACTION_SCROLL) {
if (event.getSource() == InputDevice.SOURCE_ROTARY_ENCODER) {
MotionEvent mouseEvent = MotionEvent.obtain(event);
mouseEvent.setSource(InputDevice.SOURCE_MOUSE);
CarUiRecyclerView.super.onGenericMotionEvent(mouseEvent);
return true;
}
}
return false;
} : null);
// If rotary scrolling is enabled, mark this view as focusable. This view will be focused
// when no focusable elements are visible.
setFocusable(rotaryScrollEnabled);
// Focus this view before descendants so that the RotaryService can focus this view when it
// wants to.
setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
// Disable the default focus highlight. No highlight should appear when this view is
// focused.
setDefaultFocusHighlightEnabled(false);
// This view is a rotary container if it's not a scrollable container.
if (!rotaryScrollEnabled) {
super.setContentDescription(ROTARY_CONTAINER);
}
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
super.onRestoreInstanceState(state);
// If we're restoring an existing RecyclerView, consider
// it as having already scrolled some.
mHasScrolled = true;
}
@Override
public void requestLayout() {
super.requestLayout();
if (mScrollBar != null) {
mScrollBar.requestLayout();
}
}
/**
* Sets the number of columns in which grid needs to be divided.
*/
public void setNumOfColumns(int numberOfColumns) {
mNumOfColumns = numberOfColumns;
if (mTopOffsetItemDecorationGrid != null) {
mTopOffsetItemDecorationGrid.setNumOfColumns(mNumOfColumns);
}
if (mDividerItemDecorationGrid != null) {
mDividerItemDecorationGrid.setNumOfColumns(mNumOfColumns);
}
}
@Override
public void setVisibility(int visibility) {
super.setVisibility(visibility);
mContainerVisibility = visibility;
if (mContainer != null) {
mContainer.setVisibility(visibility);
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mCarUxRestrictionsUtil.register(mListener);
if (mInstallingExtScrollBar || !mScrollBarEnabled) {
return;
}
// When CarUiRV is detached from the current parent and attached to the container with
// the scrollBar, onAttachedToWindow() will get called immediately when attaching the
// CarUiRV to the container. This flag will help us keep track of this state and avoid
// recursion. We also want to reset the state of this flag as soon as the container is
// successfully attached to the CarUiRV's original parent.
mInstallingExtScrollBar = true;
installExternalScrollBar();
mInstallingExtScrollBar = false;
}
/**
* This method will detach the current recycler view from its parent and attach it to the
* container which is a LinearLayout. Later the entire container is attached to the parent where
* the recycler view was set with the same layout params.
*/
private void installExternalScrollBar() {
LayoutInflater inflater = LayoutInflater.from(getContext());
inflater.inflate(R.layout.car_ui_recycler_view, mContainer, true);
mContainer.setVisibility(mContainerVisibility);
if (mContainerPadding != null) {
mContainer.setPadding(mContainerPadding.left, mContainerPadding.top,
mContainerPadding.right, mContainerPadding.bottom);
} else if (mContainerPaddingRelative != null) {
mContainer.setPaddingRelative(mContainerPaddingRelative.left,
mContainerPaddingRelative.top, mContainerPaddingRelative.right,
mContainerPaddingRelative.bottom);
} else {
mContainer.setPadding(getPaddingLeft(), /* top= */ 0,
getPaddingRight(), /* bottom= */ 0);
setPadding(/* left= */ 0, getPaddingTop(),
/* right= */ 0, getPaddingBottom());
}
mContainer.setLayoutParams(getLayoutParams());
ViewGroup parent = (ViewGroup) getParent();
int index = parent.indexOfChild(this);
parent.removeViewInLayout(this);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
((CarUiRecyclerViewContainer) Objects.requireNonNull(
findViewByRefId(mContainer, R.id.car_ui_recycler_view)))
.addRecyclerView(this, params);
parent.addView(mContainer, index);
createScrollBarFromConfig(findViewByRefId(mContainer, R.id.car_ui_scroll_bar));
}
private void createScrollBarFromConfig(View scrollView) {
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(this, scrollView);
setScrollBarPadding(mScrollBarPaddingTop, mScrollBarPaddingBottom);
}
@Override
public void setAlpha(float value) {
if (mScrollBarEnabled) {
mContainer.setAlpha(value);
} else {
super.setAlpha(value);
}
}
@Override
public ViewPropertyAnimator animate() {
return mScrollBarEnabled ? mContainer.animate() : super.animate();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mCarUxRestrictionsUtil.unregister(mListener);
}
@Override
public void setPadding(int left, int top, int right, int bottom) {
mContainerPaddingRelative = null;
if (mScrollBarEnabled) {
super.setPadding(0, top, 0, bottom);
if (!mHasScrolled) {
// If we haven't scrolled, and thus are still at the top of the screen,
// we should stay scrolled to the top after applying padding. Without this
// scroll, the padding will start scrolled offscreen. We need the padding
// to be onscreen to shift the content into a good visible range.
scrollToPosition(0);
}
mContainerPadding = new Rect(left, 0, right, 0);
if (mContainer != null) {
mContainer.setPadding(left, 0, right, 0);
}
setScrollBarPadding(mScrollBarPaddingTop, mScrollBarPaddingBottom);
} else {
super.setPadding(left, top, right, bottom);
}
}
@Override
public void setPaddingRelative(int start, int top, int end, int bottom) {
mContainerPadding = null;
if (mScrollBarEnabled) {
super.setPaddingRelative(0, top, 0, bottom);
if (!mHasScrolled) {
// If we haven't scrolled, and thus are still at the top of the screen,
// we should stay scrolled to the top after applying padding. Without this
// scroll, the padding will start scrolled offscreen. We need the padding
// to be onscreen to shift the content into a good visible range.
scrollToPosition(0);
}
mContainerPaddingRelative = new Rect(start, 0, end, 0);
if (mContainer != null) {
mContainer.setPaddingRelative(start, 0, end, 0);
}
setScrollBarPadding(mScrollBarPaddingTop, mScrollBarPaddingBottom);
} else {
super.setPaddingRelative(start, top, end, bottom);
}
}
/**
* Sets the scrollbar's padding top and bottom. This padding is applied in addition to the
* padding of the RecyclerView.
*/
public void setScrollBarPadding(int paddingTop, int paddingBottom) {
if (mScrollBarEnabled) {
mScrollBarPaddingTop = paddingTop;
mScrollBarPaddingBottom = paddingBottom;
if (mScrollBar != null) {
mScrollBar.setPadding(paddingTop + getPaddingTop(),
paddingBottom + getPaddingBottom());
}
}
}
/**
* Sets divider item decoration for linear layout.
*/
public void setLinearDividerItemDecoration(boolean enableDividers) {
if (enableDividers) {
addItemDecoration(mDividerItemDecorationLinear);
return;
}
removeItemDecoration(mDividerItemDecorationLinear);
}
/**
* Sets divider item decoration for grid layout.
*/
public void setGridDividerItemDecoration(boolean enableDividers) {
if (enableDividers) {
addItemDecoration(mDividerItemDecorationGrid);
return;
}
removeItemDecoration(mDividerItemDecorationGrid);
}
@Override
public void setContentDescription(CharSequence contentDescription) {
super.setContentDescription(contentDescription);
initRotaryScroll(/* styledAttributes= */ null);
}
private static RuntimeException andLog(String msg, Throwable t) {
Log.e(TAG, msg, t);
throw new RuntimeException(msg, t);
}
private class UxRestrictionChangedListener implements
CarUxRestrictionsUtil.OnUxRestrictionsChangedListener {
@Override
public void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions) {
Adapter<?> adapter = getAdapter();
// If the adapter does not implement ItemCap, then the max items on it cannot be
// updated.
if (!(adapter instanceof ItemCap)) {
return;
}
int maxItems = ItemCap.UNLIMITED;
if ((carUxRestrictions.getActiveRestrictions()
& CarUxRestrictions.UX_RESTRICTIONS_LIMIT_CONTENT)
!= 0) {
maxItems = carUxRestrictions.getMaxCumulativeContentItems();
}
int originalCount = adapter.getItemCount();
((ItemCap) adapter).setMaxItems(maxItems);
int newCount = adapter.getItemCount();
if (newCount == originalCount) {
return;
}
if (newCount < originalCount) {
adapter.notifyItemRangeRemoved(newCount, originalCount - newCount);
} else {
adapter.notifyItemRangeInserted(originalCount, newCount - originalCount);
}
}
}
}