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