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