Home | History | Annotate | Download | only in radio
      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 
     78     public CarouselView(Context context) {
     79         super(context);
     80         init(context, null);
     81     }
     82 
     83     public CarouselView(Context context, AttributeSet attrs) {
     84         super(context, attrs);
     85         init(context, attrs);
     86     }
     87 
     88     public CarouselView(Context context, AttributeSet attrs, int defStyleAttrs) {
     89         super(context, attrs, defStyleAttrs);
     90         init(context, attrs);
     91     }
     92 
     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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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
    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
    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      */
    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      */
    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      */
    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      */
    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          */
    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          */
    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          */
    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          */
    483         public final void notifyDataSetChanged() {
    484             if (mObservers.size() > 0) {
    485                 for (CarouselView carouselView : mObservers) {
    486                     carouselView.requestLayout();
    487                 }
    488             }
    489         }
    490     }
    491 }
    492