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
17package com.android.car.radio;
18
19import android.content.Context;
20import android.content.res.TypedArray;
21import android.database.Observable;
22import android.util.AttributeSet;
23import android.util.DisplayMetrics;
24import android.util.Log;
25import android.view.View;
26import android.view.ViewGroup;
27import android.view.WindowManager;
28
29import 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 */
37public 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
72    public CarouselView(Context context) {
73        super(context);
74        init(context, null);
75    }
76
77    public CarouselView(Context context, AttributeSet attrs) {
78        super(context, attrs);
79        init(context, attrs);
80    }
81
82    public CarouselView(Context context, AttributeSet attrs, int defStyleAttrs) {
83        super(context, attrs, defStyleAttrs);
84        init(context, attrs);
85    }
86
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     */
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     */
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     */
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     */
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     */
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
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
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     */
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     */
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     */
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     */
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         */
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         */
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         */
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         */
469        public final void notifyDataSetChanged() {
470            if (mObservers.size() > 0) {
471                for (CarouselView carouselView : mObservers) {
472                    carouselView.requestLayout();
473                }
474            }
475        }
476    }
477}
478