• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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