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