1 /* 2 * Copyright (C) 2016 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 17 package com.android.car.radio; 18 19 import android.content.Context; 20 import android.content.res.TypedArray; 21 import android.database.Observable; 22 import android.util.AttributeSet; 23 import android.util.DisplayMetrics; 24 import android.util.Log; 25 import android.view.View; 26 import android.view.ViewGroup; 27 import android.view.WindowManager; 28 29 import java.util.ArrayList; 30 31 /** 32 * A View that displays a vertical list of child views provided by a {@link CarouselView.Adapter}. 33 * The Views can be shifted up and down and will loop backwards on itself if the end is reached. 34 * The View that is considered first to be displayed can be offset by a given amount, and the rest 35 * of the Views will sandwich that first View. 36 */ 37 public class CarouselView extends ViewGroup { 38 private static final String TAG = "CarouselView"; 39 40 /** 41 * The alpha is that is used for the view considered first in the carousel. 42 */ 43 private static final float FIRST_VIEW_ALPHA = 1.f; 44 45 /** 46 * The alpha for all the other views in the carousel. 47 */ 48 private static final float DEFAULT_VIEW_ALPHA = 0.24f; 49 50 /** 51 * The number of additional views to bind other than the ones that fit on the screen. These 52 * additional views will allow for a smooth animation when the carousel is shifted. 53 */ 54 private static final int EXTRA_VIEWS_TO_BIND = 2; 55 56 private CarouselView.Adapter mAdapter; 57 private int mTopOffset; 58 private int mItemMargin; 59 60 /** 61 * The position into the the data set in {@link #mAdapter} that will be displayed as the first 62 * item in the carousel. 63 */ 64 private int mStartPosition; 65 66 /** 67 * The number of views in {@link #mScrapViews} that have been bound with data and should be 68 * displayed in the carousel. This number can be different from the size of {@code mScrapViews}. 69 */ 70 private int mBoundViews; 71 72 /** 73 * A {@link ArrayList} of scrap Views that can be used to populate the carousel. The views 74 * contained in this scrap will be the ones that are returned {@link #mAdapter}. 75 */ 76 private ArrayList<View> mScrapViews = new ArrayList<>(); 77 CarouselView(Context context)78 public CarouselView(Context context) { 79 super(context); 80 init(context, null); 81 } 82 CarouselView(Context context, AttributeSet attrs)83 public CarouselView(Context context, AttributeSet attrs) { 84 super(context, attrs); 85 init(context, attrs); 86 } 87 CarouselView(Context context, AttributeSet attrs, int defStyleAttrs)88 public CarouselView(Context context, AttributeSet attrs, int defStyleAttrs) { 89 super(context, attrs, defStyleAttrs); 90 init(context, attrs); 91 } 92 CarouselView(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)93 public CarouselView(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) { 94 super(context, attrs, defStyleAttrs, defStyleRes); 95 init(context, attrs); 96 } 97 98 /** 99 * Initializes the starting top offset and margins between each of the items in the carousel. 100 */ init(Context context, AttributeSet attrs)101 private void init(Context context, AttributeSet attrs) { 102 TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CarouselView); 103 104 try { 105 setTopOffset(ta.getDimensionPixelSize(R.styleable.CarouselView_topOffset, 0)); 106 setItemMargins(ta.getDimensionPixelSize(R.styleable.CarouselView_itemMargins, 0)); 107 } finally { 108 ta.recycle(); 109 } 110 } 111 112 /** 113 * Sets the adapter that will provide the Views to be displayed in the carousel. 114 */ setAdapter(CarouselView.Adapter adapter)115 public void setAdapter(CarouselView.Adapter adapter) { 116 if (Log.isLoggable(TAG, Log.DEBUG)) { 117 Log.d(TAG, "setAdapter(): " + adapter); 118 } 119 120 if (mAdapter != null) { 121 mAdapter.unregisterAll(); 122 } 123 124 mAdapter = adapter; 125 126 // Clear the scrap views because the Views returned from the adapter can be different from 127 // an adapter that was previously set. 128 mScrapViews.clear(); 129 130 if (mAdapter != null) { 131 if (Log.isLoggable(TAG, Log.DEBUG)) { 132 Log.d(TAG, "adapter item count: " + adapter.getItemCount()); 133 } 134 135 mScrapViews.ensureCapacity(adapter.getItemCount()); 136 mAdapter.registerObserver(this); 137 } 138 } 139 140 /** 141 * Sets the position within the data set of this carousel's adapter that will be displayed as 142 * the first item in the carousel. 143 */ setStartPosition(int position)144 public void setStartPosition(int position) { 145 mStartPosition = position; 146 } 147 148 /** 149 * Sets the amount by which the first view in the carousel will be offset from the top of the 150 * carousel. The last item and second item will sandwich this first view and expand upwards 151 * and downwards respectively as space permits. 152 * 153 * <p>This value can be set in XML with the value {@code app:topOffset}. 154 */ setTopOffset(int topOffset)155 public void setTopOffset(int topOffset) { 156 if (Log.isLoggable(TAG, Log.DEBUG)) { 157 Log.d(TAG, "setTopOffset(): " + topOffset); 158 } 159 160 mTopOffset = topOffset; 161 } 162 163 /** 164 * Sets the amount of space between each item in the carousel. 165 * 166 * <p>This value can be set in XML with the value {@code app:itemMargins}. 167 */ setItemMargins(int itemMargin)168 public void setItemMargins(int itemMargin) { 169 if (Log.isLoggable(TAG, Log.DEBUG)) { 170 Log.d(TAG, "setItemMargins(): " + itemMargin); 171 } 172 173 mItemMargin = itemMargin; 174 } 175 176 /** 177 * Shifts the carousel to the specified position. 178 */ shiftToPosition(int position)179 public void shiftToPosition(int position) { 180 if (mAdapter == null || position >= mAdapter.getItemCount() || position < 0) { 181 return; 182 } 183 184 mStartPosition = position; 185 requestLayout(); 186 } 187 188 @Override onMeasure(int widthSpec, int heightSpec)189 protected void onMeasure(int widthSpec, int heightSpec) { 190 if (Log.isLoggable(TAG, Log.DEBUG)) { 191 Log.d(TAG, "onMeasure()"); 192 } 193 194 removeAllViewsInLayout(); 195 196 // If there is no adapter, then have the carousel take up no space. 197 if (mAdapter == null) { 198 Log.w(TAG, "No adapter set on this CarouselView. " 199 + "Setting measured dimensions as (0, 0)"); 200 setMeasuredDimension(0, 0); 201 return; 202 } 203 204 int widthMode = MeasureSpec.getMode(widthSpec); 205 int heightMode = MeasureSpec.getMode(heightSpec); 206 207 int requestedHeight; 208 if (heightMode == MeasureSpec.UNSPECIFIED) { 209 requestedHeight = getDefaultHeight(); 210 } else { 211 requestedHeight = MeasureSpec.getSize(heightSpec); 212 } 213 214 int requestedWidth; 215 if (widthMode == MeasureSpec.UNSPECIFIED) { 216 requestedWidth = getDefaultWidth(); 217 } else { 218 requestedWidth = MeasureSpec.getSize(widthSpec); 219 } 220 221 // The children of this carousel can take up as much space as this carousel has been 222 // set to. 223 int childWidthSpec = MeasureSpec.makeMeasureSpec(requestedWidth, MeasureSpec.AT_MOST); 224 int childHeightSpec = MeasureSpec.makeMeasureSpec(requestedHeight, MeasureSpec.AT_MOST); 225 226 int availableHeight = requestedHeight; 227 int largestWidth = 0; 228 int itemCount = mAdapter.getItemCount(); 229 int currentAdapterPosition = mStartPosition; 230 231 mBoundViews = 0; 232 233 if (Log.isLoggable(TAG, Log.DEBUG)) { 234 Log.d(TAG, String.format("onMeasure(); requestedWidth: %d, requestedHeight: %d, " 235 + "availableHeight: %d", requestedWidth, requestedHeight, availableHeight)); 236 } 237 238 int availableHeightDownwards = availableHeight - mTopOffset; 239 240 // Starting from the top offset, measure the views that can fit downwards. 241 while (availableHeightDownwards >= 0) { 242 View childView = getChildView(mBoundViews); 243 244 mAdapter.bindView(childView, currentAdapterPosition, 245 currentAdapterPosition == mStartPosition); 246 mBoundViews++; 247 248 // Ensure that only the first view has full alpha. 249 if (currentAdapterPosition == mStartPosition) { 250 childView.setAlpha(FIRST_VIEW_ALPHA); 251 } else { 252 childView.setAlpha(DEFAULT_VIEW_ALPHA); 253 } 254 255 childView.measure(childWidthSpec, childHeightSpec); 256 257 largestWidth = Math.max(largestWidth, childView.getMeasuredWidth()); 258 availableHeightDownwards -= childView.getMeasuredHeight(); 259 260 // Wrap the current adapter position if necessary. 261 if (++currentAdapterPosition == itemCount) { 262 currentAdapterPosition = 0; 263 } 264 265 if (Log.isLoggable(TAG, Log.VERBOSE)) { 266 Log.v(TAG, "Measuring views downwards; current position: " 267 + currentAdapterPosition); 268 } 269 270 // Break if there are no more views to bind. 271 if (mBoundViews == itemCount) { 272 break; 273 } 274 } 275 276 int availableHeightUpwards = mTopOffset; 277 currentAdapterPosition = mStartPosition; 278 279 // Starting from the top offset, measure the views that can fit upwards. 280 while (availableHeightUpwards >= 0) { 281 // Wrap the current adapter position if necessary. 282 if (--currentAdapterPosition < 0) { 283 currentAdapterPosition = itemCount - 1; 284 } 285 286 if (Log.isLoggable(TAG, Log.VERBOSE)) { 287 Log.v(TAG, "Measuring views upwards; current position: " 288 + currentAdapterPosition); 289 } 290 291 View childView = getChildView(mBoundViews); 292 293 mAdapter.bindView(childView, currentAdapterPosition, 294 currentAdapterPosition == mStartPosition); 295 mBoundViews++; 296 297 // We know that the first view will be measured in the "downwards" pass, so all these 298 // views can have DEFAULT_VIEW_ALPHA. 299 childView.setAlpha(DEFAULT_VIEW_ALPHA); 300 childView.measure(childWidthSpec, childHeightSpec); 301 302 largestWidth = Math.max(largestWidth, childView.getMeasuredWidth()); 303 availableHeightUpwards -= childView.getMeasuredHeight(); 304 305 // Break if there are no more views to bind. 306 if (mBoundViews == itemCount) { 307 break; 308 } 309 } 310 311 int width = widthMode == MeasureSpec.EXACTLY 312 ? requestedWidth 313 : Math.min(largestWidth, requestedWidth); 314 315 if (Log.isLoggable(TAG, Log.DEBUG)) { 316 Log.d(TAG, String.format("Measure finished. Largest width is %s; " 317 + "setting final width as %s.", largestWidth, width)); 318 } 319 320 setMeasuredDimension(width, requestedHeight); 321 } 322 323 @Override onLayout(boolean changed, int l, int t, int r, int b)324 protected void onLayout(boolean changed, int l, int t, int r, int b) { 325 int height = b - t; 326 int width = r - l; 327 328 int top = mTopOffset; 329 int viewsLaidOut = 0; 330 int currentPosition = 0; 331 LayoutParams layoutParams = getLayoutParams(); 332 333 // Double check that the item count has not changed since the views have been bound. 334 if (mBoundViews > mAdapter.getItemCount()) { 335 return; 336 } 337 338 // Start laying out the views from the first position downwards. 339 for (; viewsLaidOut < mBoundViews; viewsLaidOut++) { 340 View childView = mScrapViews.get(currentPosition); 341 addViewInLayout(childView, -1, layoutParams); 342 int measuredHeight = childView.getMeasuredHeight(); 343 344 childView.layout(width - childView.getMeasuredWidth(), top, width, 345 top + measuredHeight); 346 347 top += mItemMargin + measuredHeight; 348 349 // Wrap the current position if necessary. 350 if (++currentPosition >= mBoundViews) { 351 currentPosition = 0; 352 } 353 354 // Check if there is still space to fit another view. If not, then stop layout. 355 if (top >= height) { 356 // Increase the number of views laid out by 1 since this usually will happen at the 357 // end of the loop, but we are breaking out of it. 358 viewsLaidOut++; 359 break; 360 } 361 } 362 363 if (Log.isLoggable(TAG, Log.DEBUG)) { 364 Log.d(TAG, String.format("onLayout(). First pass laid out %s views", viewsLaidOut)); 365 } 366 367 // Reset the top position to the first position's top and the starting position. 368 top = mTopOffset; 369 currentPosition = 0; 370 371 // Now, if there are any views remaining, back-fill the space above the first position. 372 for (; viewsLaidOut < mBoundViews; viewsLaidOut++) { 373 // Wrap the current position if necessary. Since this is a back-fill, we will subtract 374 // from the current position. 375 if (--currentPosition < 0) { 376 currentPosition = mBoundViews - 1; 377 } 378 379 View childView = mScrapViews.get(currentPosition); 380 addViewInLayout(childView, -1, layoutParams); 381 int measuredHeight = childView.getMeasuredHeight(); 382 383 top -= measuredHeight + mItemMargin; 384 385 childView.layout(width - childView.getMeasuredWidth(), top, width, 386 top + measuredHeight); 387 388 // Check if there is still space to fit another view. 389 if (top <= 0) { 390 // Although this value is not technically needed, increasing its value so that the 391 // debug statement will print out the correct value. 392 viewsLaidOut++; 393 break; 394 } 395 } 396 397 if (Log.isLoggable(TAG, Log.DEBUG)) { 398 Log.d(TAG, String.format("onLayout(). Second pass total laid out %s views", 399 viewsLaidOut)); 400 } 401 } 402 403 /** 404 * Returns the {@link View} that should be drawn at the given position. 405 */ getChildView(int position)406 private View getChildView(int position) { 407 View childView; 408 409 // Check if there is already a View in the scrap pile of Views that can be used. Otherwise, 410 // create a new View and add it to the scrap. 411 if (mScrapViews.size() > position) { 412 childView = mScrapViews.get(position); 413 } else { 414 childView = mAdapter.createView(this /* parent */); 415 mScrapViews.add(childView); 416 } 417 418 return childView; 419 } 420 421 /** 422 * Returns the default height that the {@link CarouselView} will take up. This will be the 423 * height of the current screen. 424 */ getDefaultHeight()425 private int getDefaultHeight() { 426 return getDisplayMetrics(getContext()).heightPixels; 427 } 428 429 /** 430 * Returns the default width that the {@link CarouselView} will take up. This will be the width 431 * of the current screen. 432 */ getDefaultWidth()433 private int getDefaultWidth() { 434 return getDisplayMetrics(getContext()).widthPixels; 435 } 436 437 /** 438 * Returns a {@link DisplayMetrics} object that can be used to query the height and width of the 439 * current device's screen. 440 */ getDisplayMetrics(Context context)441 private static DisplayMetrics getDisplayMetrics(Context context) { 442 WindowManager windowManager = (WindowManager) context.getSystemService( 443 Context.WINDOW_SERVICE); 444 DisplayMetrics displayMetrics = new DisplayMetrics(); 445 windowManager.getDefaultDisplay().getMetrics(displayMetrics); 446 return displayMetrics; 447 } 448 449 /** 450 * A data set adapter for the {@link CarouselView} that is responsible for providing the views 451 * to be displayed as well as binding data on those views. 452 */ 453 public static abstract class Adapter extends Observable<CarouselView> { 454 /** 455 * Returns a View to be displayed. The views returned should all be the same. 456 * 457 * @param parent The {@link CarouselView} that the views will be attached to. 458 * @return A non-{@code null} View. 459 */ createView(ViewGroup parent)460 public abstract View createView(ViewGroup parent); 461 462 /** 463 * Binds the given View with data. The View passed to this method will be the same View 464 * returned by {@link #createView(ViewGroup)}. 465 * 466 * @param view The View to bind with data. 467 * @param position The position of the View in the carousel. 468 * @param isFirstView {@code true} if the view being bound is the first view in the 469 * carousel. 470 */ bindView(View view, int position, boolean isFirstView)471 public abstract void bindView(View view, int position, boolean isFirstView); 472 473 /** 474 * Returns the total number of unique items that will be displayed in the 475 * {@link CarouselView}. 476 */ getItemCount()477 public abstract int getItemCount(); 478 479 /** 480 * Notify the {@link CarouselView} that the data set has changed. This will cause the 481 * {@link CarouselView} to re-layout itself. 482 */ notifyDataSetChanged()483 public final void notifyDataSetChanged() { 484 if (mObservers.size() > 0) { 485 for (CarouselView carouselView : mObservers) { 486 carouselView.requestLayout(); 487 } 488 } 489 } 490 } 491 } 492