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