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