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