Home | History | Annotate | Download | only in view
      1 /*
      2  * Copyright (C) 2017 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 package com.android.car.view;
     17 
     18 import android.content.Context;
     19 import android.content.res.Resources;
     20 import android.content.res.TypedArray;
     21 import android.graphics.Canvas;
     22 import android.graphics.Paint;
     23 import android.graphics.Rect;
     24 import android.os.Handler;
     25 import android.support.annotation.IdRes;
     26 import android.support.annotation.NonNull;
     27 import android.support.annotation.Nullable;
     28 import android.support.v7.widget.RecyclerView;
     29 import android.util.AttributeSet;
     30 import android.util.Log;
     31 import android.view.LayoutInflater;
     32 import android.view.MotionEvent;
     33 import android.view.View;
     34 import android.widget.FrameLayout;
     35 
     36 import com.android.car.stream.ui.R;
     37 
     38 /**
     39  * Custom {@link android.support.v7.widget.RecyclerView} that displays a list of items that
     40  * resembles a {@link android.widget.ListView} but also has page up and page down arrows
     41  * on the right side.
     42  */
     43 public class PagedListView extends FrameLayout {
     44     private static final String TAG = "PagedListView";
     45 
     46     /**
     47      * The amount of time after settling to wait before autoscrolling to the next page when the
     48      * user holds down a pagination button.
     49      */
     50     private static final int PAGINATION_HOLD_DELAY_MS = 400;
     51     private static final int INVALID_RESOURCE_ID = -1;
     52 
     53     private final CarRecyclerView mRecyclerView;
     54     private final CarLayoutManager mLayoutManager;
     55     private final PagedScrollBarView mScrollBarView;
     56     private final Handler mHandler = new Handler();
     57     private DividerDecoration mDecor;
     58 
     59     /** Maximum number of pages to show. Values < 0 show all pages. */
     60     private int mMaxPages = -1;
     61     /** Number of visible rows per page */
     62     private int mRowsPerPage = -1;
     63 
     64     /**
     65      * Used to check if there are more items added to the list.
     66      */
     67     private int mLastItemCount = 0;
     68 
     69     private RecyclerView.Adapter<? extends RecyclerView.ViewHolder> mAdapter;
     70 
     71     private boolean mNeedsFocus;
     72     private OnScrollBarListener mOnScrollBarListener;
     73 
     74     /**
     75      * Interface for a {@link android.support.v7.widget.RecyclerView.Adapter} to cap the
     76      * number of items.
     77      * <p>NOTE: it is still up to the adapter to use maxItems in
     78      * {@link android.support.v7.widget.RecyclerView.Adapter#getItemCount()}.
     79      *
     80      * the recommended way would be with:
     81      * <pre>
     82      * @Override
     83      * public int getItemCount() {
     84      *     return Math.min(super.getItemCount(), mMaxItems);
     85      * }
     86      * </pre>
     87      */
     88     public interface ItemCap {
     89         int UNLIMITED = -1;
     90 
     91         /**
     92          * Sets the maximum number of items available in the adapter. A value less than '0'
     93          * means the list should not be capped.
     94          */
     95         void setMaxItems(int maxItems);
     96     }
     97 
     98     public PagedListView(Context context, AttributeSet attrs) {
     99         this(context, attrs, 0 /*defStyleAttrs*/, 0 /*defStyleRes*/);
    100     }
    101 
    102     public PagedListView(Context context, AttributeSet attrs, int defStyleAttrs) {
    103         this(context, attrs, defStyleAttrs, 0 /*defStyleRes*/);
    104     }
    105 
    106     public PagedListView(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) {
    107         super(context, attrs, defStyleAttrs, defStyleRes);
    108         TypedArray a = context.obtainStyledAttributes(
    109                 attrs, R.styleable.PagedListView, defStyleAttrs, defStyleRes);
    110         LayoutInflater.from(context)
    111                 .inflate(R.layout.car_paged_recycler_view, this /*root*/, true /*attachToRoot*/);
    112         int scrollContainerWidth = getResources().getDimensionPixelSize(
    113                 R.dimen.car_drawer_button_container_width);
    114         if (a.hasValue(R.styleable.PagedListView_scrollbarContainerWidth)) {
    115             scrollContainerWidth = a.getDimensionPixelSize(
    116                     R.styleable.PagedListView_scrollbarContainerWidth,
    117                     scrollContainerWidth);
    118             FrameLayout scrollContainer = (FrameLayout) findViewById(R.id.scroll_container);
    119             LayoutParams params = (LayoutParams) scrollContainer.getLayoutParams();
    120             params.width = scrollContainerWidth;
    121             scrollContainer.setLayoutParams(params);
    122         }
    123 
    124         boolean offsetScrollBar = a.getBoolean(R.styleable.PagedListView_offsetScrollBar, false);
    125         if (offsetScrollBar) {
    126             FrameLayout maxWidthLayout = (FrameLayout) findViewById(R.id.max_width_layout);
    127             LayoutParams params = (LayoutParams) maxWidthLayout.getLayoutParams();
    128             params.leftMargin = scrollContainerWidth;
    129             params.rightMargin = a.getDimensionPixelSize(R.styleable.PagedListView_rightMargin, 0);
    130             maxWidthLayout.setLayoutParams(params);
    131         }
    132         mRecyclerView = (CarRecyclerView) findViewById(R.id.recycler_view);
    133         boolean fadeLastItem = a.getBoolean(R.styleable.PagedListView_fadeLastItem, false);
    134         mRecyclerView.setFadeLastItem(fadeLastItem);
    135         boolean offsetRows = a.getBoolean(R.styleable.PagedListView_offsetRows, false);
    136 
    137         mMaxPages = getDefaultMaxPages();
    138 
    139         mLayoutManager = new CarLayoutManager(context);
    140         mLayoutManager.setOffsetRows(offsetRows);
    141         mLayoutManager.setItemsChangedListener(mItemsChangedListener);
    142         mRecyclerView.setLayoutManager(mLayoutManager);
    143         mRecyclerView.setOnScrollListener(mOnScrollListener);
    144         mRecyclerView.getRecycledViewPool().setMaxRecycledViews(0, 12);
    145         mRecyclerView.setItemAnimator(new CarItemAnimator(mLayoutManager));
    146 
    147         if (a.getBoolean(R.styleable.PagedListView_showDivider, true)) {
    148             int dividerStartMargin = a.getDimensionPixelSize(
    149                     R.styleable.PagedListView_dividerStartMargin, 0);
    150             int dividerStartId = a.getResourceId(R.styleable.PagedListView_alignDividerStartTo,
    151                     INVALID_RESOURCE_ID);
    152             int dividerEndId = a.getResourceId(R.styleable.PagedListView_alignDividerEndTo,
    153                     INVALID_RESOURCE_ID);
    154 
    155             mRecyclerView.addItemDecoration(new DividerDecoration(context, dividerStartMargin,
    156                     dividerStartId, dividerEndId));
    157         }
    158 
    159         mScrollBarView = (PagedScrollBarView) findViewById(R.id.paged_scroll_view);
    160         mScrollBarView.setPaginationListener(new PagedScrollBarView.PaginationListener() {
    161             @Override
    162             public void onPaginate(int direction) {
    163                 if (direction == PagedScrollBarView.PaginationListener.PAGE_UP) {
    164                     mRecyclerView.pageUp();
    165                 } else if (direction == PagedScrollBarView.PaginationListener.PAGE_DOWN) {
    166                     mRecyclerView.pageDown();
    167                 } else {
    168                     Log.e(TAG, "Unknown pagination direction (" + direction + ")");
    169                 }
    170             }
    171         });
    172 
    173         setAutoDayNightMode();
    174         updatePaginationButtons(false /*animate*/);
    175 
    176         a.recycle();
    177     }
    178 
    179     @Override
    180     protected void onDetachedFromWindow() {
    181         super.onDetachedFromWindow();
    182         mHandler.removeCallbacks(mUpdatePaginationRunnable);
    183     }
    184 
    185     @Override
    186     public boolean onInterceptTouchEvent(MotionEvent e) {
    187         if (e.getAction() == MotionEvent.ACTION_DOWN) {
    188             // The user has interacted with the list using touch. All movements will now paginate
    189             // the list.
    190             mLayoutManager.setRowOffsetMode(CarLayoutManager.ROW_OFFSET_MODE_PAGE);
    191         }
    192         return super.onInterceptTouchEvent(e);
    193     }
    194 
    195     @Override
    196     public void requestChildFocus(View child, View focused) {
    197         super.requestChildFocus(child, focused);
    198         // The user has interacted with the list using the controller. Movements through the list
    199         // will now be one row at a time.
    200         mLayoutManager.setRowOffsetMode(CarLayoutManager.ROW_OFFSET_MODE_INDIVIDUAL);
    201     }
    202 
    203     public int positionOf(@Nullable View v) {
    204         if (v == null || v.getParent() != mRecyclerView) {
    205             return -1;
    206         }
    207         return mLayoutManager.getPosition(v);
    208     }
    209 
    210     @NonNull
    211     public CarRecyclerView getRecyclerView() {
    212         return mRecyclerView;
    213     }
    214 
    215     public void scrollToPosition(int position) {
    216         mLayoutManager.scrollToPosition(position);
    217 
    218         // Sometimes #scrollToPosition doesn't change the scroll state so we need to make sure
    219         // the pagination arrows actually get updated.
    220         mHandler.post(mUpdatePaginationRunnable);
    221     }
    222 
    223     /**
    224      * Sets the adapter for the list.
    225      * <p>It <em>must</em> implement {@link ItemCap}, otherwise, will throw
    226      * an {@link IllegalArgumentException}.
    227      */
    228     public void setAdapter(
    229             @NonNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter) {
    230         if (!(adapter instanceof ItemCap)) {
    231             throw new IllegalArgumentException("ERROR: adapter "
    232                     + "[" + adapter.getClass().getCanonicalName() + "] MUST implement ItemCap");
    233         }
    234 
    235         mAdapter = adapter;
    236         mRecyclerView.setAdapter(adapter);
    237         tryUpdateMaxPages();
    238     }
    239 
    240     @NonNull
    241     public CarLayoutManager getLayoutManager() {
    242         return mLayoutManager;
    243     }
    244 
    245     @Nullable
    246     @SuppressWarnings("unchecked")
    247     public RecyclerView.Adapter<? extends RecyclerView.ViewHolder> getAdapter() {
    248         return mRecyclerView.getAdapter();
    249     }
    250 
    251     public void setMaxPages(int maxPages) {
    252         mMaxPages = maxPages;
    253         tryUpdateMaxPages();
    254     }
    255 
    256     public int getMaxPages() {
    257         return mMaxPages;
    258     }
    259 
    260     public void resetMaxPages() {
    261         mMaxPages = getDefaultMaxPages();
    262     }
    263 
    264     public void addItemDecoration(@NonNull RecyclerView.ItemDecoration decor) {
    265         mRecyclerView.addItemDecoration(decor);
    266     }
    267 
    268     public void removeItemDecoration(@NonNull RecyclerView.ItemDecoration decor) {
    269         mRecyclerView.removeItemDecoration(decor);
    270     }
    271 
    272     /**
    273      * Sets the scrollbars of this PagedListView to change from light to dark colors depending on
    274      * whether or not device is in night mode.
    275      */
    276     public void setAutoDayNightMode() {
    277         mScrollBarView.setAutoDayNightMode();
    278     }
    279 
    280     /**
    281      * Sets the scrollbars of this PagedListView to be light colors.
    282      */
    283     public void setLightMode() {
    284         mScrollBarView.setLightMode();
    285     }
    286 
    287     /**
    288      * Sets the scrollbars of this PagedListView to be dark colors.
    289      */
    290     public void setDarkMode() {
    291         mScrollBarView.setDarkMode();
    292     }
    293 
    294     public void setOnScrollBarListener(OnScrollBarListener listener) {
    295         mOnScrollBarListener = listener;
    296     }
    297 
    298     /** Returns the page the given position is on, starting with page 0. */
    299     public int getPage(int position) {
    300         if (mRowsPerPage == -1) {
    301             return -1;
    302         }
    303         return position / mRowsPerPage;
    304     }
    305 
    306     /** Returns the default number of pages the list should have */
    307     protected int getDefaultMaxPages() {
    308         // assume list shown in response to a click, so, reduce number of clicks by one
    309         //return ProjectionUtils.getMaxClicks(getContext().getContentResolver()) - 1;
    310         return 5;
    311     }
    312 
    313     private void tryUpdateMaxPages() {
    314         if (mAdapter == null) {
    315             return;
    316         }
    317 
    318         View firstChild = mLayoutManager.getChildAt(0);
    319         int firstRowHeight = firstChild == null ? 0 : firstChild.getHeight();
    320         mRowsPerPage = firstRowHeight == 0 ? 1 : getHeight() / firstRowHeight;
    321 
    322         int newMaxItems;
    323         if (mMaxPages < 0) {
    324             newMaxItems = -1;
    325         } else if (mMaxPages == 0) {
    326             // At the last click of 6 click limit, we show one more warning item at the top of menu.
    327             newMaxItems = mRowsPerPage + 1;
    328         } else {
    329             newMaxItems = mRowsPerPage * mMaxPages;
    330         }
    331 
    332         int originalCount = mAdapter.getItemCount();
    333         ((ItemCap) mAdapter).setMaxItems(newMaxItems);
    334         int newCount = mAdapter.getItemCount();
    335         if (newCount < originalCount) {
    336             mAdapter.notifyItemRangeChanged(newCount, originalCount);
    337         } else if (newCount > originalCount) {
    338             mAdapter.notifyItemInserted(originalCount);
    339         }
    340     }
    341 
    342     @Override
    343     public void onLayout(boolean changed, int left, int top, int right, int bottom) {
    344         // if a late item is added to the top of the layout after the layout is stabilized, causing
    345         // the former top item to be pushed to the 2nd page, the focus will still be on the former
    346         // top item. Since our car layout manager tries to scroll the viewport so that the focused
    347         // item is visible, the view port will be on the 2nd page. That means the newly added item
    348         // will not be visible, on the first page.
    349 
    350         // what we want to do is: if the formerly focused item is the first one in the list, any
    351         // item added above it will make the focus to move to the new first item.
    352         // if the focus is not on the formerly first item, then we don't need to do anything. Let
    353         // the layout manager do the job and scroll the viewport so the currently focused item
    354         // is visible.
    355 
    356         // we need to calculate whether we want to request focus here, before the super call,
    357         // because after the super call, the first born might be changed.
    358         View focusedChild = mLayoutManager.getFocusedChild();
    359         View firstBorn = mLayoutManager.getChildAt(0);
    360 
    361         super.onLayout(changed, left, top, right, bottom);
    362 
    363         if (mAdapter != null) {
    364             int itemCount = mAdapter.getItemCount();
    365            // if () {
    366                 Log.d(TAG, String.format(
    367                         "onLayout hasFocus: %s, mLastItemCount: %s, itemCount: %s, focusedChild: " +
    368                                 "%s, firstBorn: %s, isInTouchMode: %s, mNeedsFocus: %s",
    369                         hasFocus(), mLastItemCount, itemCount, focusedChild, firstBorn,
    370                         isInTouchMode(), mNeedsFocus));
    371           //  }
    372             tryUpdateMaxPages();
    373             // This is a workaround for missing focus because isInTouchMode() is not always
    374             // returning the right value.
    375             // This is okay for the Engine release since focus is always showing.
    376             // However, in Tala and Fender, we want to show focus only when the user uses
    377             // hardware controllers, so we need to revisit this logic. b/22990605.
    378             if (mNeedsFocus && itemCount > 0) {
    379                 if (focusedChild == null) {
    380                     requestFocusFromTouch();
    381                 }
    382                 mNeedsFocus = false;
    383             }
    384             if (itemCount > mLastItemCount && focusedChild == firstBorn &&
    385                     getContext().getResources().getBoolean(R.bool.has_wheel)) {
    386                 requestFocusFromTouch();
    387             }
    388             mLastItemCount = itemCount;
    389         }
    390         updatePaginationButtons(true /*animate*/);
    391     }
    392 
    393     @Override
    394     public boolean requestFocus(int direction, Rect rect) {
    395         if (getContext().getResources().getBoolean(R.bool.has_wheel)) {
    396             mNeedsFocus = true;
    397         }
    398         return super.requestFocus(direction, rect);
    399     }
    400 
    401     public View findViewByPosition(int position) {
    402         return mLayoutManager.findViewByPosition(position);
    403     }
    404 
    405     private void updatePaginationButtons(boolean animate) {
    406         boolean isAtTop = mLayoutManager.isAtTop();
    407         boolean isAtBottom = mLayoutManager.isAtBottom();
    408         if (isAtTop && isAtBottom) {
    409             mScrollBarView.setVisibility(View.INVISIBLE);
    410         } else {
    411             mScrollBarView.setVisibility(View.VISIBLE);
    412         }
    413         mScrollBarView.setUpEnabled(!isAtTop);
    414         mScrollBarView.setDownEnabled(!isAtBottom);
    415 
    416         mScrollBarView.setParameters(
    417                 mRecyclerView.computeVerticalScrollRange(),
    418                 mRecyclerView.computeVerticalScrollOffset(),
    419                 mRecyclerView.computeVerticalScrollExtent(),
    420                 animate);
    421         invalidate();
    422     }
    423 
    424     private final RecyclerView.OnScrollListener mOnScrollListener =
    425             new RecyclerView.OnScrollListener() {
    426 
    427         @Override
    428         public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    429             if (mOnScrollBarListener != null) {
    430                 if (!mLayoutManager.isAtTop() && mLayoutManager.isAtBottom()) {
    431                     mOnScrollBarListener.onReachBottom();
    432                 }
    433                 if (mLayoutManager.isAtTop() || !mLayoutManager.isAtBottom()) {
    434                     mOnScrollBarListener.onLeaveBottom();
    435                 }
    436             }
    437             updatePaginationButtons(false);
    438         }
    439 
    440         @Override
    441         public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
    442             if (newState == RecyclerView.SCROLL_STATE_IDLE) {
    443                 mHandler.postDelayed(mPaginationRunnable, PAGINATION_HOLD_DELAY_MS);
    444             }
    445         }
    446     };
    447     private final Runnable mPaginationRunnable = new Runnable() {
    448         @Override
    449         public void run() {
    450             boolean upPressed = mScrollBarView.isUpPressed();
    451             boolean downPressed = mScrollBarView.isDownPressed();
    452             if (upPressed && downPressed) {
    453                 // noop
    454             } else if (upPressed) {
    455                 mRecyclerView.pageUp();
    456             } else if (downPressed) {
    457                 mRecyclerView.pageDown();
    458             }
    459         }
    460     };
    461 
    462     private final Runnable mUpdatePaginationRunnable = new Runnable() {
    463         @Override
    464         public void run() {
    465             updatePaginationButtons(true /*animate*/);
    466         }
    467     };
    468 
    469     private final CarLayoutManager.OnItemsChangedListener mItemsChangedListener =
    470             new CarLayoutManager.OnItemsChangedListener() {
    471                 @Override
    472                 public void onItemsChanged() {
    473                     updatePaginationButtons(true /*animate*/);
    474                 }
    475             };
    476 
    477     abstract static public class OnScrollBarListener {
    478         public void onReachBottom() {}
    479         public void onLeaveBottom() {}
    480     }
    481 
    482     /**
    483      * A {@link android.support.v7.widget.RecyclerView.ItemDecoration} that will draw a dividing
    484      * line between each item in the RecyclerView that it is added to.
    485      */
    486     public static class DividerDecoration extends RecyclerView.ItemDecoration {
    487         private final Paint mPaint;
    488         private final int mDividerHeight;
    489         private final int mDividerStartMargin;
    490         @IdRes private final int mDividerStartId;
    491         @IdRes private final int mDvidierEndId;
    492 
    493         /**
    494          * @param dividerStartMargin The start offset of the dividing line. This offset will be
    495          *                           relative to {@code dividerStartId} if that value is given.
    496          * @param dividerStartId A child view id whose starting edge will be used as the starting
    497          *                       edge of the dividing line. If this value is
    498          *                       {@link #INVALID_RESOURCE_ID}, the the top container of each
    499          *                       child view will be used.
    500          * @param dividerEndId A child view id whose ending edge will be used as the starting edge
    501          *                     of the dividing lin.e If this value is {@link #INVALID_RESOURCE_ID},
    502          *                     then the top container view of each child will be used.
    503          */
    504         private DividerDecoration(Context context, int dividerStartMargin,
    505                 @IdRes int dividerStartId, @IdRes int dividerEndId) {
    506             mDividerStartMargin = dividerStartMargin;
    507             mDividerStartId = dividerStartId;
    508             mDvidierEndId = dividerEndId;
    509 
    510             Resources res = context.getResources();
    511             mPaint = new Paint();
    512             mPaint.setColor(res.getColor(R.color.car_list_divider));
    513             mDividerHeight = res.getDimensionPixelSize(R.dimen.car_divider_height);
    514         }
    515 
    516         @Override
    517         public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
    518             for (int i = 0, childCount = parent.getChildCount(); i < childCount; i++) {
    519                 View container = parent.getChildAt(i);
    520                 View startChild = mDividerStartId != INVALID_RESOURCE_ID
    521                         ? container.findViewById(mDividerStartId)
    522                         : container;
    523 
    524                 View endChild = mDvidierEndId != INVALID_RESOURCE_ID
    525                         ? container.findViewById(mDvidierEndId)
    526                         : container;
    527 
    528                 if (startChild == null || endChild == null) {
    529                     continue;
    530                 }
    531 
    532                 int left = mDividerStartMargin + startChild.getLeft();
    533                 int right = endChild.getRight();
    534                 int bottom = container.getBottom();
    535                 int top = bottom - mDividerHeight;
    536 
    537                 // Draw a divider line between each item. No need to draw the line for the last
    538                 // item.
    539                 if (i != childCount - 1) {
    540                     c.drawRect(left, top, right, bottom, mPaint);
    541                 }
    542             }
    543         }
    544     }
    545 }
    546