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          */
setMaxItems(int maxItems)94         void setMaxItems(int maxItems);
95     }
96 
PagedListView(Context context, AttributeSet attrs)97     public PagedListView(Context context, AttributeSet attrs) {
98         this(context, attrs, 0 /*defStyleAttrs*/, 0 /*defStyleRes*/);
99     }
100 
PagedListView(Context context, AttributeSet attrs, int defStyleAttrs)101     public PagedListView(Context context, AttributeSet attrs, int defStyleAttrs) {
102         this(context, attrs, defStyleAttrs, 0 /*defStyleRes*/);
103     }
104 
PagedListView(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)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
onDetachedFromWindow()156     protected void onDetachedFromWindow() {
157         super.onDetachedFromWindow();
158         mHandler.removeCallbacks(mUpdatePaginationRunnable);
159     }
160 
161     @Override
onInterceptTouchEvent(MotionEvent e)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
requestChildFocus(View child, View focused)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 
positionOf(@ullable View v)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
getRecyclerView()187     public CarRecyclerView getRecyclerView() {
188         return mRecyclerView;
189     }
190 
scrollToPosition(int position)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      */
setAdapter( @onNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter)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
getLayoutManager()217     public CarLayoutManager getLayoutManager() {
218         return mLayoutManager;
219     }
220 
221     @Nullable
222     @SuppressWarnings("unchecked")
getAdapter()223     public RecyclerView.Adapter<? extends RecyclerView.ViewHolder> getAdapter() {
224         return mRecyclerView.getAdapter();
225     }
226 
setMaxPages(int maxPages)227     public void setMaxPages(int maxPages) {
228         mMaxPages = maxPages;
229         tryUpdateMaxPages();
230     }
231 
getMaxPages()232     public int getMaxPages() {
233         return mMaxPages;
234     }
235 
resetMaxPages()236     public void resetMaxPages() {
237         mMaxPages = getDefaultMaxPages();
238     }
239 
setDefaultItemDecoration(Decoration decor)240     public void setDefaultItemDecoration(Decoration decor) {
241         removeDefaultItemDecoration();
242         mDecor = decor;
243         addItemDecoration(mDecor);
244     }
245 
removeDefaultItemDecoration()246     public void removeDefaultItemDecoration() {
247         mRecyclerView.removeItemDecoration(mDecor);
248     }
249 
addItemDecoration(@onNull RecyclerView.ItemDecoration decor)250     public void addItemDecoration(@NonNull RecyclerView.ItemDecoration decor) {
251         mRecyclerView.addItemDecoration(decor);
252     }
253 
removeItemDecoration(@onNull RecyclerView.ItemDecoration decor)254     public void removeItemDecoration(@NonNull RecyclerView.ItemDecoration decor) {
255         mRecyclerView.removeItemDecoration(decor);
256     }
257 
setAutoDayNightMode()258     public void setAutoDayNightMode() {
259         mScrollBarView.setAutoDayNightMode();
260         mDecor.updateDividerColor();
261     }
262 
setLightMode()263     public void setLightMode() {
264         mScrollBarView.setLightMode();
265         mDecor.updateDividerColor();
266     }
267 
setDarkMode()268     public void setDarkMode() {
269         mScrollBarView.setDarkMode();
270         mDecor.updateDividerColor();
271     }
272 
setOnScrollBarListener(OnScrollBarListener listener)273     public void setOnScrollBarListener(OnScrollBarListener listener) {
274         mOnScrollBarListener = listener;
275     }
276 
277     /** Returns the page the given position is on, starting with page 0. */
getPage(int position)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 */
getDefaultMaxPages()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 
tryUpdateMaxPages()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
onLayout(boolean changed, int left, int top, int right, int bottom)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
requestFocus(int direction, Rect rect)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 
findViewByPosition(int position)380     public View findViewByPosition(int position) {
381         return mLayoutManager.findViewByPosition(position);
382     }
383 
updatePaginationButtons(boolean animate)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 {
onReachBottom()457         public void onReachBottom() {}
onLeaveBottom()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 
Decoration(Context context)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
onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state)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          */
updateDividerColor()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          */
getLeft(View root)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 
findTextView(View root)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