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 }