| /* |
| * 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.apps.common.widget; |
| |
| import static java.lang.annotation.RetentionPolicy.SOURCE; |
| |
| import android.car.drivingstate.CarUxRestrictions; |
| import android.content.Context; |
| import android.content.res.TypedArray; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.ViewTreeObserver.OnGlobalLayoutListener; |
| |
| import androidx.annotation.IntDef; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.recyclerview.widget.LinearLayoutManager; |
| import androidx.recyclerview.widget.RecyclerView; |
| |
| import com.android.car.apps.common.CarUxRestrictionsUtil; |
| import com.android.car.apps.common.R; |
| import com.android.car.apps.common.util.ScrollBarUI; |
| |
| import java.lang.annotation.Retention; |
| |
| /** |
| * View that extends a {@link RecyclerView} and creates a nested {@code RecyclerView} with an option |
| * to render a custom scroll bar 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 Context mContext; |
| |
| private CarUxRestrictionsUtil mCarUxRestrictionsUtil; |
| private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener; |
| |
| private boolean mScrollBarEnabled; |
| private int mScrollBarContainerWidth; |
| private @ScrollBarPosition int mScrollBarPosition; |
| private boolean mScrollBarAboveRecyclerView; |
| private String mScrollBarClass; |
| private int mScrollBarPaddingStart; |
| private int mScrollBarPaddingEnd; |
| private boolean mFullyInitialized; |
| |
| @Gutter |
| private int mGutter; |
| private int mGutterSize; |
| private RecyclerView mNestedRecyclerView; |
| private Adapter mAdapter; |
| private ScrollBarUI mScrollBarUI; |
| |
| /** |
| * 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; |
| } |
| |
| /** |
| * 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 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; |
| } |
| } |
| |
| 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); |
| |
| try { |
| mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(context); |
| } catch (NullPointerException e) { |
| // Do nothing, mCarUxRestrictionsUtil will be null |
| } |
| 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.PagedRecyclerView); |
| |
| mScrollBarEnabled = a.getBoolean(R.styleable.PagedRecyclerView_scrollBarEnabled, |
| /* defValue= */true); |
| mFullyInitialized = false; |
| |
| if (!mScrollBarEnabled) { |
| a.recycle(); |
| mFullyInitialized = true; |
| return; |
| } |
| |
| mContext = context; |
| mNestedRecyclerView = new RecyclerView(mContext, attrs, |
| R.style.PagedRecyclerView_NestedRecyclerView); |
| |
| PagedRecyclerViewLayoutManager layoutManager = new PagedRecyclerViewLayoutManager(context); |
| super.setLayoutManager(layoutManager); |
| |
| PagedRecyclerViewAdapter adapter = new PagedRecyclerViewAdapter(); |
| super.setAdapter(adapter); |
| |
| super.setNestedScrollingEnabled(false); |
| super.setClipToPadding(false); |
| |
| // Gutter |
| int defaultGutterSize = getResources().getDimensionPixelSize(R.dimen.car_scroll_bar_margin); |
| mGutter = a.getInt(R.styleable.PagedRecyclerView_gutter, Gutter.BOTH); |
| mGutterSize = defaultGutterSize; |
| |
| int carMargin = mContext.getResources().getDimensionPixelSize( |
| R.dimen.car_scroll_bar_margin); |
| mScrollBarContainerWidth = a.getDimensionPixelSize( |
| R.styleable.PagedRecyclerView_scrollBarContainerWidth, carMargin); |
| |
| mScrollBarPosition = a.getInt(R.styleable.PagedRecyclerView_scrollBarPosition, |
| ScrollBarPosition.START); |
| |
| mScrollBarAboveRecyclerView = a.getBoolean( |
| R.styleable.PagedRecyclerView_scrollBarAboveRecyclerView, /* defValue= */true); |
| |
| mScrollBarClass = a.getString(R.styleable.PagedRecyclerView_scrollBarCustomClass); |
| a.recycle(); |
| |
| // 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(); |
| |
| createScrollBarFromConfig(); |
| |
| mNestedRecyclerView.getViewTreeObserver().addOnGlobalLayoutListener( |
| new OnGlobalLayoutListener() { |
| @Override |
| public void onGlobalLayout() { |
| mNestedRecyclerView.getViewTreeObserver() |
| .removeOnGlobalLayoutListener(this); |
| mFullyInitialized = true; |
| } |
| }); |
| } |
| }); |
| } |
| |
| /** |
| * Returns {@code true} if the {@PagedRecyclerView} is fully drawn. Using a global layout |
| * listener 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; |
| } |
| |
| @Override |
| protected void onAttachedToWindow() { |
| super.onAttachedToWindow(); |
| if (mCarUxRestrictionsUtil != null) { |
| mCarUxRestrictionsUtil.register(mListener); |
| } |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| if (mCarUxRestrictionsUtil != null) { |
| 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); |
| } |
| } |
| |
| @Override |
| public void setAdapter(@Nullable Adapter adapter) { |
| 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); |
| } |
| } |
| |
| /** |
| * 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 |
| 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 (mScrollBarUI != null) mScrollBarUI.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 (mScrollBarUI != null) mScrollBarUI.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); |
| } |
| } |
| |
| /** |
| * Calls {@link #layout(int, int, int, int)} for both this RecyclerView and the nested one. |
| */ |
| @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) |
| public void layoutBothForTesting(int l, int t, int r, int b) { |
| super.layout(l, t, r, b); |
| mNestedRecyclerView.layout(l, t, r, b); |
| } |
| |
| @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.mFrameLayout.addView(mNestedRecyclerView); |
| } |
| |
| private void createScrollBarFromConfig() { |
| if (DEBUG) Log.d(TAG, "createScrollBarFromConfig"); |
| final String clsName = mScrollBarClass == null |
| ? mContext.getString(R.string.config_scrollBarComponent) : mScrollBarClass; |
| if (clsName == null || clsName.length() == 0) { |
| throw andLog("No scroll bar component configured", null); |
| } |
| |
| Class<?> cls; |
| try { |
| cls = mContext.getClassLoader().loadClass(clsName); |
| } catch (Throwable t) { |
| throw andLog("Error loading scroll bar component: " + clsName, t); |
| } |
| try { |
| mScrollBarUI = (ScrollBarUI) cls.newInstance(); |
| } catch (Throwable t) { |
| throw andLog("Error creating scroll bar component: " + clsName, t); |
| } |
| |
| mScrollBarUI.initialize(mContext, mNestedRecyclerView, mScrollBarContainerWidth, |
| mScrollBarPosition, mScrollBarAboveRecyclerView); |
| |
| mScrollBarUI.setPadding(mScrollBarPaddingStart, mScrollBarPaddingEnd); |
| |
| if (DEBUG) Log.d(TAG, "started " + mScrollBarUI.getClass().getSimpleName()); |
| } |
| |
| /** |
| * Sets the scrollbar's padding start (top) and end (bottom). |
| * This padding is applied in addition to the padding of the inner RecyclerView. |
| */ |
| public void setScrollBarPadding(int paddingStart, int paddingEnd) { |
| if (mScrollBarEnabled) { |
| mScrollBarPaddingStart = paddingStart; |
| mScrollBarPaddingEnd = paddingEnd; |
| |
| if (mScrollBarUI != null) { |
| mScrollBarUI.setPadding(paddingStart, paddingEnd); |
| } |
| } |
| } |
| |
| /** |
| * Set the nested view's layout to the specified value. |
| * |
| * <p>The gutter 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 gutter 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 gutter, set ClipToPadding to false so that CardView's shadow will still |
| // appear outside of the padding. |
| mNestedRecyclerView.setClipToPadding(startMargin == 0 && endMargin == 0); |
| } |
| |
| private RuntimeException andLog(String msg, Throwable t) { |
| Log.e(TAG, msg, t); |
| throw new RuntimeException(msg, t); |
| } |
| } |