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