| /* |
| * Copyright (C) 2017 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.view; |
| |
| import android.content.Context; |
| import android.content.res.TypedArray; |
| import android.graphics.Canvas; |
| import android.graphics.Paint; |
| import android.graphics.Rect; |
| import android.os.Handler; |
| import android.support.annotation.NonNull; |
| import android.support.annotation.Nullable; |
| import android.support.v7.widget.RecyclerView; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.LayoutInflater; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.widget.FrameLayout; |
| import android.widget.TextView; |
| |
| import com.android.car.stream.ui.R; |
| |
| /** |
| * Custom {@link android.support.v7.widget.RecyclerView} that displays a list of items that |
| * resembles a {@link android.widget.ListView} but also has page up and page down arrows |
| * on the right side. |
| */ |
| public class PagedListView extends FrameLayout { |
| private static final String TAG = "PagedListView"; |
| |
| /** |
| * The amount of time after settling to wait before autoscrolling to the next page when the |
| * user holds down a pagination button. |
| */ |
| private static final int PAGINATION_HOLD_DELAY_MS = 400; |
| |
| private final CarRecyclerView mRecyclerView; |
| private final CarLayoutManager mLayoutManager; |
| private final PagedScrollBarView mScrollBarView; |
| private final Handler mHandler = new Handler(); |
| private DividerDecoration mDecor; |
| |
| /** Maximum number of pages to show. Values < 0 show all pages. */ |
| private int mMaxPages = -1; |
| /** Number of visible rows per page */ |
| private int mRowsPerPage = -1; |
| |
| /** |
| * Used to check if there are more items added to the list. |
| */ |
| private int mLastItemCount = 0; |
| |
| private RecyclerView.Adapter<? extends RecyclerView.ViewHolder> mAdapter; |
| |
| private boolean mNeedsFocus; |
| private OnScrollBarListener mOnScrollBarListener; |
| |
| /** |
| * Interface for a {@link android.support.v7.widget.RecyclerView.Adapter} to cap the |
| * number of items. |
| * <p>NOTE: it is still up to the adapter to use maxItems in |
| * {@link android.support.v7.widget.RecyclerView.Adapter#getItemCount()}. |
| * |
| * the recommended way would be with: |
| * <pre> |
| * @Override |
| * public int getItemCount() { |
| * return Math.min(super.getItemCount(), mMaxItems); |
| * } |
| * </pre> |
| */ |
| public interface ItemCap { |
| public static final 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 PagedListView(Context context, AttributeSet attrs) { |
| this(context, attrs, 0 /*defStyleAttrs*/, 0 /*defStyleRes*/); |
| } |
| |
| public PagedListView(Context context, AttributeSet attrs, int defStyleAttrs) { |
| this(context, attrs, defStyleAttrs, 0 /*defStyleRes*/); |
| } |
| |
| public PagedListView(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) { |
| super(context, attrs, defStyleAttrs, defStyleRes); |
| TypedArray a = context.obtainStyledAttributes( |
| attrs, R.styleable.PagedListView, defStyleAttrs, defStyleRes); |
| LayoutInflater.from(context) |
| .inflate(R.layout.car_paged_recycler_view, this /*root*/, true /*attachToRoot*/); |
| int scrollContainerWidth = getResources().getDimensionPixelSize( |
| R.dimen.car_drawer_button_container_width); |
| if (a.hasValue(R.styleable.PagedListView_scrollbarContainerWidth)) { |
| scrollContainerWidth = a.getDimensionPixelSize( |
| R.styleable.PagedListView_scrollbarContainerWidth, |
| scrollContainerWidth); |
| FrameLayout scrollContainer = (FrameLayout) findViewById(R.id.scroll_container); |
| LayoutParams params = (LayoutParams) scrollContainer.getLayoutParams(); |
| params.width = scrollContainerWidth; |
| scrollContainer.setLayoutParams(params); |
| } |
| |
| boolean offsetScrollBar = a.getBoolean(R.styleable.PagedListView_offsetScrollBar, false); |
| if (offsetScrollBar) { |
| FrameLayout maxWidthLayout = (FrameLayout) findViewById(R.id.max_width_layout); |
| LayoutParams params = (LayoutParams) maxWidthLayout.getLayoutParams(); |
| params.leftMargin = scrollContainerWidth; |
| int rightMargin = a.getDimensionPixelSize(R.styleable.PagedListView_rightMargin, 0); |
| params.rightMargin = rightMargin; |
| maxWidthLayout.setLayoutParams(params); |
| } |
| boolean showDivider = a.getBoolean(R.styleable.PagedListView_showDivider, true); |
| mDecor = showDivider |
| ? new DividerDecoration(getContext()) : new NoDividerDecoration(getContext()); |
| |
| mRecyclerView = (CarRecyclerView) findViewById(R.id.recycler_view); |
| boolean fadeLastItem = a.getBoolean(R.styleable.PagedListView_fadeLastItem, false); |
| mRecyclerView.setFadeLastItem(fadeLastItem); |
| boolean offsetRows = a.getBoolean(R.styleable.PagedListView_offsetRows, false); |
| a.recycle(); |
| |
| mMaxPages = getDefaultMaxPages(); |
| |
| mLayoutManager = new CarLayoutManager(context); |
| mLayoutManager.setOffsetRows(offsetRows); |
| mLayoutManager.setItemsChangedListener(mItemsChangedListener); |
| mRecyclerView.setLayoutManager(mLayoutManager); |
| mRecyclerView.addItemDecoration(mDecor); |
| mRecyclerView.setOnScrollListener(mOnScrollListener); |
| mRecyclerView.getRecycledViewPool().setMaxRecycledViews(0, 12); |
| mRecyclerView.setItemAnimator(new CarItemAnimator(mLayoutManager)); |
| |
| mScrollBarView = (PagedScrollBarView) findViewById(R.id.paged_scroll_view); |
| mScrollBarView.setPaginationListener(new PagedScrollBarView.PaginationListener() { |
| @Override |
| public void onPaginate(int direction) { |
| if (direction == PagedScrollBarView.PaginationListener.PAGE_UP) { |
| mRecyclerView.pageUp(); |
| } else if (direction == PagedScrollBarView.PaginationListener.PAGE_DOWN) { |
| mRecyclerView.pageDown(); |
| } else { |
| Log.e(TAG, "Unknown pagination direction (" + direction + ")"); |
| } |
| } |
| }); |
| |
| setAutoDayNightMode(); |
| updatePaginationButtons(false /*animate*/); |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| mHandler.removeCallbacks(mUpdatePaginationRunnable); |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent e) { |
| if (e.getAction() == MotionEvent.ACTION_DOWN) { |
| // The user has interacted with the list using touch. All movements will now paginate |
| // the list. |
| mLayoutManager.setRowOffsetMode(CarLayoutManager.ROW_OFFSET_MODE_PAGE); |
| } |
| return super.onInterceptTouchEvent(e); |
| } |
| |
| @Override |
| public void requestChildFocus(View child, View focused) { |
| super.requestChildFocus(child, focused); |
| // The user has interacted with the list using the controller. Movements through the list |
| // will now be one row at a time. |
| mLayoutManager.setRowOffsetMode(CarLayoutManager.ROW_OFFSET_MODE_INDIVIDUAL); |
| } |
| |
| public int positionOf(@Nullable View v) { |
| if (v == null || v.getParent() != mRecyclerView) { |
| return -1; |
| } |
| return mLayoutManager.getPosition(v); |
| } |
| |
| @NonNull |
| public CarRecyclerView getRecyclerView() { |
| return mRecyclerView; |
| } |
| |
| public void scrollToPosition(int position) { |
| mLayoutManager.scrollToPosition(position); |
| |
| // Sometimes #scrollToPosition doesn't change the scroll state so we need to make sure |
| // the pagination arrows actually get updated. |
| mHandler.post(mUpdatePaginationRunnable); |
| } |
| |
| /** |
| * Sets the adapter for the list. |
| * <p>It <em>must</em> implement {@link ItemCap}, otherwise, will throw |
| * an {@link IllegalArgumentException}. |
| */ |
| public void setAdapter( |
| @NonNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter) { |
| if (!(adapter instanceof ItemCap)) { |
| throw new IllegalArgumentException("ERROR: adapter " |
| + "[" + adapter.getClass().getCanonicalName() + "] MUST implement ItemCap"); |
| } |
| |
| mAdapter = adapter; |
| mRecyclerView.setAdapter(adapter); |
| tryUpdateMaxPages(); |
| } |
| |
| @NonNull |
| public CarLayoutManager getLayoutManager() { |
| return mLayoutManager; |
| } |
| |
| @Nullable |
| @SuppressWarnings("unchecked") |
| public RecyclerView.Adapter<? extends RecyclerView.ViewHolder> getAdapter() { |
| return mRecyclerView.getAdapter(); |
| } |
| |
| public void setMaxPages(int maxPages) { |
| mMaxPages = maxPages; |
| tryUpdateMaxPages(); |
| } |
| |
| public int getMaxPages() { |
| return mMaxPages; |
| } |
| |
| public void resetMaxPages() { |
| mMaxPages = getDefaultMaxPages(); |
| } |
| |
| public void setDefaultItemDecoration(DividerDecoration decor) { |
| removeDefaultItemDecoration(); |
| mDecor = decor; |
| addItemDecoration(mDecor); |
| } |
| |
| public void removeDefaultItemDecoration() { |
| mRecyclerView.removeItemDecoration(mDecor); |
| } |
| |
| public void addItemDecoration(@NonNull RecyclerView.ItemDecoration decor) { |
| mRecyclerView.addItemDecoration(decor); |
| } |
| |
| public void removeItemDecoration(@NonNull RecyclerView.ItemDecoration decor) { |
| mRecyclerView.removeItemDecoration(decor); |
| } |
| |
| public void setAutoDayNightMode() { |
| mScrollBarView.setAutoDayNightMode(); |
| mDecor.updateDividerColor(); |
| } |
| |
| public void setLightMode() { |
| mScrollBarView.setLightMode(); |
| mDecor.updateDividerColor(); |
| } |
| |
| public void setDarkMode() { |
| mScrollBarView.setDarkMode(); |
| mDecor.updateDividerColor(); |
| } |
| |
| public void setOnScrollBarListener(OnScrollBarListener listener) { |
| mOnScrollBarListener = listener; |
| } |
| |
| /** Returns the page the given position is on, starting with page 0. */ |
| public int getPage(int position) { |
| if (mRowsPerPage == -1) { |
| return -1; |
| } |
| return position / mRowsPerPage; |
| } |
| |
| /** Returns the default number of pages the list should have */ |
| protected int getDefaultMaxPages() { |
| // assume list shown in response to a click, so, reduce number of clicks by one |
| //return ProjectionUtils.getMaxClicks(getContext().getContentResolver()) - 1; |
| return 5; |
| } |
| |
| private void tryUpdateMaxPages() { |
| if (mAdapter == null) { |
| return; |
| } |
| |
| View firstChild = mLayoutManager.getChildAt(0); |
| int firstRowHeight = firstChild == null ? 0 : firstChild.getHeight(); |
| mRowsPerPage = firstRowHeight == 0 ? 1 : getHeight() / firstRowHeight; |
| |
| int newMaxItems; |
| if (mMaxPages < 0) { |
| newMaxItems = -1; |
| } else if (mMaxPages == 0) { |
| // At the last click of 6 click limit, we show one more warning item at the top of menu. |
| newMaxItems = mRowsPerPage + 1; |
| } else { |
| newMaxItems = mRowsPerPage * mMaxPages; |
| } |
| |
| int originalCount = mAdapter.getItemCount(); |
| ((ItemCap) mAdapter).setMaxItems(newMaxItems); |
| int newCount = mAdapter.getItemCount(); |
| if (newCount < originalCount) { |
| mAdapter.notifyItemRangeChanged(newCount, originalCount); |
| } else if (newCount > originalCount) { |
| mAdapter.notifyItemInserted(originalCount); |
| } |
| } |
| |
| @Override |
| public void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| // if a late item is added to the top of the layout after the layout is stabilized, causing |
| // the former top item to be pushed to the 2nd page, the focus will still be on the former |
| // top item. Since our car layout manager tries to scroll the viewport so that the focused |
| // item is visible, the view port will be on the 2nd page. That means the newly added item |
| // will not be visible, on the first page. |
| |
| // what we want to do is: if the formerly focused item is the first one in the list, any |
| // item added above it will make the focus to move to the new first item. |
| // if the focus is not on the formerly first item, then we don't need to do anything. Let |
| // the layout manager do the job and scroll the viewport so the currently focused item |
| // is visible. |
| |
| // we need to calculate whether we want to request focus here, before the super call, |
| // because after the super call, the first born might be changed. |
| View focusedChild = mLayoutManager.getFocusedChild(); |
| View firstBorn = mLayoutManager.getChildAt(0); |
| |
| super.onLayout(changed, left, top, right, bottom); |
| |
| if (mAdapter != null) { |
| int itemCount = mAdapter.getItemCount(); |
| // if () { |
| Log.d(TAG, String.format( |
| "onLayout hasFocus: %s, mLastItemCount: %s, itemCount: %s, focusedChild: " + |
| "%s, firstBorn: %s, isInTouchMode: %s, mNeedsFocus: %s", |
| hasFocus(), mLastItemCount, itemCount, focusedChild, firstBorn, |
| isInTouchMode(), mNeedsFocus)); |
| // } |
| tryUpdateMaxPages(); |
| // This is a workaround for missing focus because isInTouchMode() is not always |
| // returning the right value. |
| // This is okay for the Engine release since focus is always showing. |
| // However, in Tala and Fender, we want to show focus only when the user uses |
| // hardware controllers, so we need to revisit this logic. b/22990605. |
| if (mNeedsFocus && itemCount > 0) { |
| if (focusedChild == null) { |
| requestFocusFromTouch(); |
| } |
| mNeedsFocus = false; |
| } |
| if (itemCount > mLastItemCount && focusedChild == firstBorn && |
| getContext().getResources().getBoolean(R.bool.has_wheel)) { |
| requestFocusFromTouch(); |
| } |
| mLastItemCount = itemCount; |
| } |
| updatePaginationButtons(true /*animate*/); |
| } |
| |
| @Override |
| public boolean requestFocus(int direction, Rect rect) { |
| if (getContext().getResources().getBoolean(R.bool.has_wheel)) { |
| mNeedsFocus = true; |
| } |
| return super.requestFocus(direction, rect); |
| } |
| |
| public View findViewByPosition(int position) { |
| return mLayoutManager.findViewByPosition(position); |
| } |
| |
| private void updatePaginationButtons(boolean animate) { |
| boolean isAtTop = mLayoutManager.isAtTop(); |
| boolean isAtBottom = mLayoutManager.isAtBottom(); |
| if (isAtTop && isAtBottom) { |
| mScrollBarView.setVisibility(View.INVISIBLE); |
| } else { |
| mScrollBarView.setVisibility(View.VISIBLE); |
| } |
| mScrollBarView.setUpEnabled(!isAtTop); |
| mScrollBarView.setDownEnabled(!isAtBottom); |
| |
| mScrollBarView.setParameters( |
| mRecyclerView.computeVerticalScrollRange(), |
| mRecyclerView.computeVerticalScrollOffset(), |
| mRecyclerView.computeVerticalScrollExtent(), |
| animate); |
| invalidate(); |
| } |
| |
| private final RecyclerView.OnScrollListener mOnScrollListener = |
| new RecyclerView.OnScrollListener() { |
| |
| @Override |
| public void onScrolled(RecyclerView recyclerView, int dx, int dy) { |
| if (mOnScrollBarListener != null) { |
| if (!mLayoutManager.isAtTop() && mLayoutManager.isAtBottom()) { |
| mOnScrollBarListener.onReachBottom(); |
| } |
| if (mLayoutManager.isAtTop() || !mLayoutManager.isAtBottom()) { |
| mOnScrollBarListener.onLeaveBottom(); |
| } |
| } |
| updatePaginationButtons(false); |
| } |
| |
| @Override |
| public void onScrollStateChanged(RecyclerView recyclerView, int newState) { |
| if (newState == RecyclerView.SCROLL_STATE_IDLE) { |
| mHandler.postDelayed(mPaginationRunnable, PAGINATION_HOLD_DELAY_MS); |
| } |
| } |
| }; |
| private final Runnable mPaginationRunnable = new Runnable() { |
| @Override |
| public void run() { |
| boolean upPressed = mScrollBarView.isUpPressed(); |
| boolean downPressed = mScrollBarView.isDownPressed(); |
| if (upPressed && downPressed) { |
| // noop |
| } else if (upPressed) { |
| mRecyclerView.pageUp(); |
| } else if (downPressed) { |
| mRecyclerView.pageDown(); |
| } |
| } |
| }; |
| |
| private final Runnable mUpdatePaginationRunnable = new Runnable() { |
| @Override |
| public void run() { |
| updatePaginationButtons(true /*animate*/); |
| } |
| }; |
| |
| private final CarLayoutManager.OnItemsChangedListener mItemsChangedListener = |
| new CarLayoutManager.OnItemsChangedListener() { |
| @Override |
| public void onItemsChanged() { |
| updatePaginationButtons(true /*animate*/); |
| } |
| }; |
| |
| abstract static public class OnScrollBarListener { |
| public void onReachBottom() {} |
| public void onLeaveBottom() {} |
| } |
| |
| public static class DividerDecoration extends RecyclerView.ItemDecoration { |
| protected final Paint mPaint; |
| protected final int mDividerHeight; |
| protected final Context mContext; |
| |
| |
| public DividerDecoration(Context context) { |
| mContext = context; |
| mPaint = new Paint(); |
| updateDividerColor(); |
| mDividerHeight = mContext.getResources() |
| .getDimensionPixelSize(R.dimen.car_divider_height); |
| } |
| |
| @Override |
| public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { |
| final int left = getLeft(parent.getChildAt(0)); |
| final int right = parent.getWidth() - parent.getPaddingRight(); |
| int top; |
| int bottom; |
| |
| c.drawRect(left, 0, right, mDividerHeight, mPaint); |
| |
| final int childCount = parent.getChildCount(); |
| for (int i = 0; i < childCount; i++) { |
| final View child = parent.getChildAt(i); |
| final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child |
| .getLayoutParams(); |
| bottom = child.getBottom() - params.bottomMargin; |
| top = bottom - mDividerHeight; |
| if (top > 0) { |
| c.drawRect(left, top, right, bottom, mPaint); |
| } |
| } |
| } |
| |
| /** |
| * Updates the list divider color which may have changed due to a day night transition. |
| */ |
| public void updateDividerColor() { |
| mPaint.setColor(mContext.getResources().getColor(R.color.car_list_divider)); |
| } |
| |
| /** |
| * Find the left edge of the decoration line. It should be left aligned with the left edge |
| * of the first {@link android.widget.TextView}. |
| */ |
| private int getLeft(View root) { |
| if (root == null) { |
| return 0; |
| } |
| View view = findTextView(root); |
| if (view == null) { |
| view = root; |
| } |
| int left = 0; |
| while (view != null && view != root) { |
| left += view.getLeft(); |
| view = (View) view.getParent(); |
| } |
| return left; |
| } |
| |
| private TextView findTextView(View root) { |
| if (root == null) { |
| return null; |
| } |
| if (root instanceof TextView) { |
| return (TextView) root; |
| } |
| if (root instanceof ViewGroup) { |
| ViewGroup parent = (ViewGroup) root; |
| final int childCount = parent.getChildCount(); |
| for(int i = 0; i < childCount; i++) { |
| TextView tv = findTextView(parent.getChildAt(i)); |
| if (tv != null) { |
| return tv; |
| } |
| } |
| } |
| return null; |
| } |
| } |
| |
| public static class NoDividerDecoration extends DividerDecoration { |
| public NoDividerDecoration(Context context) { |
| super(context); |
| } |
| |
| @Override |
| public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {} |
| } |
| } |