Home | History | Annotate | Download | only in expandingcells
      1 /*
      2  * Copyright (C) 2013 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.example.android.expandingcells;
     18 
     19 import android.animation.Animator;
     20 import android.animation.AnimatorListenerAdapter;
     21 import android.animation.AnimatorSet;
     22 import android.animation.ObjectAnimator;
     23 import android.animation.PropertyValuesHolder;
     24 import android.content.Context;
     25 import android.graphics.Canvas;
     26 import android.util.AttributeSet;
     27 import android.view.View;
     28 import android.view.ViewTreeObserver;
     29 import android.widget.AbsListView;
     30 import android.widget.AdapterView;
     31 import android.widget.ListView;
     32 
     33 import java.util.ArrayList;
     34 import java.util.HashMap;
     35 import java.util.List;
     36 
     37 /**
     38  * A custom listview which supports the preview of extra content corresponding to each cell
     39  * by clicking on the cell to hide and show the extra content.
     40  */
     41 public class ExpandingListView extends ListView {
     42 
     43     private boolean mShouldRemoveObserver = false;
     44 
     45     private List<View> mViewsToDraw = new ArrayList<View>();
     46 
     47     private int[] mTranslate;
     48 
     49     public ExpandingListView(Context context) {
     50         super(context);
     51         init();
     52     }
     53 
     54     public ExpandingListView(Context context, AttributeSet attrs) {
     55         super(context, attrs);
     56         init();
     57     }
     58 
     59     public ExpandingListView(Context context, AttributeSet attrs, int defStyle) {
     60         super(context, attrs, defStyle);
     61         init();
     62     }
     63 
     64     private void init() {
     65         setOnItemClickListener(mItemClickListener);
     66     }
     67 
     68     /**
     69      * Listens for item clicks and expands or collapses the selected view depending on
     70      * its current state.
     71      */
     72     private AdapterView.OnItemClickListener mItemClickListener = new AdapterView
     73             .OnItemClickListener() {
     74         @Override
     75         public void onItemClick (AdapterView<?> parent, View view, int position, long id) {
     76             ExpandableListItem viewObject = (ExpandableListItem)getItemAtPosition(getPositionForView
     77                     (view));
     78             if (!viewObject.isExpanded()) {
     79                 expandView(view);
     80             } else {
     81                 collapseView(view);
     82             }
     83         }
     84     };
     85 
     86     /**
     87      * Calculates the top and bottom bound changes of the selected item. These values are
     88      * also used to move the bounds of the items around the one that is actually being
     89      * expanded or collapsed.
     90      *
     91      * This method can be modified to achieve different user experiences depending
     92      * on how you want the cells to expand or collapse. In this specific demo, the cells
     93      * always try to expand downwards (leaving top bound untouched), and similarly,
     94      * collapse upwards (leaving top bound untouched). If the change in bounds
     95      * results in the complete disappearance of a cell, its lower bound is moved is
     96      * moved to the top of the screen so as not to hide any additional content that
     97      * the user has not interacted with yet. Furthermore, if the collapsed cell is
     98      * partially off screen when it is first clicked, it is translated such that its
     99      * full contents are visible. Lastly, this behaviour varies slightly near the bottom
    100      * of the listview in order to account for the fact that the bottom bounds of the actual
    101      * listview cannot be modified.
    102      */
    103     private int[] getTopAndBottomTranslations(int top, int bottom, int yDelta,
    104                                               boolean isExpanding) {
    105         int yTranslateTop = 0;
    106         int yTranslateBottom = yDelta;
    107 
    108         int height = bottom - top;
    109 
    110         if (isExpanding) {
    111             boolean isOverTop = top < 0;
    112             boolean isBelowBottom = (top + height + yDelta) > getHeight();
    113             if (isOverTop) {
    114                 yTranslateTop = top;
    115                 yTranslateBottom = yDelta - yTranslateTop;
    116             } else if (isBelowBottom){
    117                 int deltaBelow = top + height + yDelta - getHeight();
    118                 yTranslateTop = top - deltaBelow < 0 ? top : deltaBelow;
    119                 yTranslateBottom = yDelta - yTranslateTop;
    120             }
    121         } else {
    122             int offset = computeVerticalScrollOffset();
    123             int range = computeVerticalScrollRange();
    124             int extent = computeVerticalScrollExtent();
    125             int leftoverExtent = range-offset - extent;
    126 
    127             boolean isCollapsingBelowBottom = (yTranslateBottom > leftoverExtent);
    128             boolean isCellCompletelyDisappearing = bottom - yTranslateBottom < 0;
    129 
    130             if (isCollapsingBelowBottom) {
    131                 yTranslateTop = yTranslateBottom - leftoverExtent;
    132                 yTranslateBottom = yDelta - yTranslateTop;
    133             } else if (isCellCompletelyDisappearing) {
    134                 yTranslateBottom = bottom;
    135                 yTranslateTop = yDelta - yTranslateBottom;
    136             }
    137         }
    138 
    139         return new int[] {yTranslateTop, yTranslateBottom};
    140     }
    141 
    142     /**
    143      * This method expands the view that was clicked and animates all the views
    144      * around it to make room for the expanding view. There are several steps required
    145      * to do this which are outlined below.
    146      *
    147      * 1. Store the current top and bottom bounds of each visible item in the listview.
    148      * 2. Update the layout parameters of the selected view. In the context of this
    149      *    method, the view should be originally collapsed and set to some custom height.
    150      *    The layout parameters are updated so as to wrap the content of the additional
    151      *    text that is to be displayed.
    152      *
    153      * After invoking a layout to take place, the listview will order all the items
    154      * such that there is space for each view. This layout will be independent of what
    155      * the bounds of the items were prior to the layout so two pre-draw passes will
    156      * be made. This is necessary because after the layout takes place, some views that
    157      * were visible before the layout may now be off bounds but a reference to these
    158      * views is required so the animation completes as intended.
    159      *
    160      * 3. The first predraw pass will set the bounds of all the visible items to
    161      *    their original location before the layout took place and then force another
    162      *    layout. Since the bounds of the cells cannot be set directly, the method
    163      *    setSelectionFromTop can be used to achieve a very similar effect.
    164      * 4. The expanding view's bounds are animated to what the final values should be
    165      *    from the original bounds.
    166      * 5. The bounds above the expanding view are animated upwards while the bounds
    167      *    below the expanding view are animated downwards.
    168      * 6. The extra text is faded in as its contents become visible throughout the
    169      *    animation process.
    170      *
    171      * It is important to note that the listview is disabled during the animation
    172      * because the scrolling behaviour is unpredictable if the bounds of the items
    173      * within the listview are not constant during the scroll.
    174      */
    175 
    176     private void expandView(final View view) {
    177         final ExpandableListItem viewObject = (ExpandableListItem)getItemAtPosition(getPositionForView
    178                 (view));
    179 
    180         /* Store the original top and bottom bounds of all the cells.*/
    181         final int oldTop = view.getTop();
    182         final int oldBottom = view.getBottom();
    183 
    184         final HashMap<View, int[]> oldCoordinates = new HashMap<View, int[]>();
    185 
    186         int childCount = getChildCount();
    187         for (int i = 0; i < childCount; i++) {
    188             View v = getChildAt(i);
    189             v.setHasTransientState(true);
    190             oldCoordinates.put(v, new int[] {v.getTop(), v.getBottom()});
    191         }
    192 
    193         /* Update the layout so the extra content becomes visible.*/
    194         final View expandingLayout = view.findViewById(R.id.expanding_layout);
    195         expandingLayout.setVisibility(View.VISIBLE);
    196 
    197         /* Add an onPreDraw Listener to the listview. onPreDraw will get invoked after onLayout
    198         * and onMeasure have run but before anything has been drawn. This
    199         * means that the final post layout properties for all the items have already been
    200         * determined, but still have not been rendered onto the screen.*/
    201         final ViewTreeObserver observer = getViewTreeObserver();
    202         observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
    203 
    204             @Override
    205             public boolean onPreDraw() {
    206                 /* Determine if this is the first or second pass.*/
    207                 if (!mShouldRemoveObserver) {
    208                     mShouldRemoveObserver = true;
    209 
    210                     /* Calculate what the parameters should be for setSelectionFromTop.
    211                     * The ListView must be offset in a way, such that after the animation
    212                     * takes place, all the cells that remain visible are rendered completely
    213                     * by the ListView.*/
    214                     int newTop = view.getTop();
    215                     int newBottom = view.getBottom();
    216 
    217                     int newHeight = newBottom - newTop;
    218                     int oldHeight = oldBottom - oldTop;
    219                     int delta = newHeight - oldHeight;
    220 
    221                     mTranslate = getTopAndBottomTranslations(oldTop, oldBottom, delta, true);
    222 
    223                     int currentTop = view.getTop();
    224                     int futureTop = oldTop - mTranslate[0];
    225 
    226                     int firstChildStartTop = getChildAt(0).getTop();
    227                     int firstVisiblePosition = getFirstVisiblePosition();
    228                     int deltaTop = currentTop - futureTop;
    229 
    230                     int i;
    231                     int childCount = getChildCount();
    232                     for (i = 0; i < childCount; i++) {
    233                         View v = getChildAt(i);
    234                         int height = v.getBottom() - Math.max(0, v.getTop());
    235                         if (deltaTop - height > 0) {
    236                             firstVisiblePosition++;
    237                             deltaTop -= height;
    238                         } else {
    239                             break;
    240                         }
    241                     }
    242 
    243                     if (i > 0) {
    244                         firstChildStartTop = 0;
    245                     }
    246 
    247                     setSelectionFromTop(firstVisiblePosition, firstChildStartTop - deltaTop);
    248 
    249                     /* Request another layout to update the layout parameters of the cells.*/
    250                     requestLayout();
    251 
    252                     /* Return false such that the ListView does not redraw its contents on
    253                      * this layout but only updates all the parameters associated with its
    254                      * children.*/
    255                     return false;
    256                 }
    257 
    258                 /* Remove the predraw listener so this method does not keep getting called. */
    259                 mShouldRemoveObserver = false;
    260                 observer.removeOnPreDrawListener(this);
    261 
    262                 int yTranslateTop = mTranslate[0];
    263                 int yTranslateBottom = mTranslate[1];
    264 
    265                 ArrayList <Animator> animations = new ArrayList<Animator>();
    266 
    267                 int index = indexOfChild(view);
    268 
    269                 /* Loop through all the views that were on the screen before the cell was
    270                 *  expanded. Some cells will still be children of the ListView while
    271                 *  others will not. The cells that remain children of the ListView
    272                 *  simply have their bounds animated appropriately. The cells that are no
    273                 *  longer children of the ListView also have their bounds animated, but
    274                 *  must also be added to a list of views which will be drawn in dispatchDraw.*/
    275                 for (View v: oldCoordinates.keySet()) {
    276                     int[] old = oldCoordinates.get(v);
    277                     v.setTop(old[0]);
    278                     v.setBottom(old[1]);
    279                     if (v.getParent() == null) {
    280                         mViewsToDraw.add(v);
    281                         int delta = old[0] < oldTop ? -yTranslateTop : yTranslateBottom;
    282                         animations.add(getAnimation(v, delta, delta));
    283                     } else {
    284                         int i = indexOfChild(v);
    285                         if (v != view) {
    286                             int delta = i > index ? yTranslateBottom : -yTranslateTop;
    287                             animations.add(getAnimation(v, delta, delta));
    288                         }
    289                         v.setHasTransientState(false);
    290                     }
    291                 }
    292 
    293                 /* Adds animation for expanding the cell that was clicked. */
    294                 animations.add(getAnimation(view, -yTranslateTop, yTranslateBottom));
    295 
    296                 /* Adds an animation for fading in the extra content. */
    297                 animations.add(ObjectAnimator.ofFloat(view.findViewById(R.id.expanding_layout),
    298                         View.ALPHA, 0, 1));
    299 
    300                 /* Disabled the ListView for the duration of the animation.*/
    301                 setEnabled(false);
    302                 setClickable(false);
    303 
    304                 /* Play all the animations created above together at the same time. */
    305                 AnimatorSet s = new AnimatorSet();
    306                 s.playTogether(animations);
    307                 s.addListener(new AnimatorListenerAdapter() {
    308                     @Override
    309                     public void onAnimationEnd(Animator animation) {
    310                         viewObject.setExpanded(true);
    311                         setEnabled(true);
    312                         setClickable(true);
    313                         if (mViewsToDraw.size() > 0) {
    314                             for (View v : mViewsToDraw) {
    315                                 v.setHasTransientState(false);
    316                             }
    317                         }
    318                         mViewsToDraw.clear();
    319                     }
    320                 });
    321                 s.start();
    322                 return true;
    323             }
    324         });
    325     }
    326 
    327     /**
    328      * By overriding dispatchDraw, we can draw the cells that disappear during the
    329      * expansion process. When the cell expands, some items below or above the expanding
    330      * cell may be moved off screen and are thus no longer children of the ListView's
    331      * layout. By storing a reference to these views prior to the layout, and
    332      * guaranteeing that these cells do not get recycled, the cells can be drawn
    333      * directly onto the canvas during the animation process. After the animation
    334      * completes, the references to the extra views can then be discarded.
    335      */
    336     @Override
    337     protected void dispatchDraw(Canvas canvas) {
    338         super.dispatchDraw(canvas);
    339 
    340         if (mViewsToDraw.size() == 0) {
    341             return;
    342         }
    343 
    344         for (View v: mViewsToDraw) {
    345             canvas.translate(0, v.getTop());
    346             v.draw(canvas);
    347             canvas.translate(0, -v.getTop());
    348         }
    349     }
    350 
    351     /**
    352      * This method collapses the view that was clicked and animates all the views
    353      * around it to close around the collapsing view. There are several steps required
    354      * to do this which are outlined below.
    355      *
    356      * 1. Update the layout parameters of the view clicked so as to minimize its height
    357      *    to the original collapsed (default) state.
    358      * 2. After invoking a layout, the listview will shift all the cells so as to display
    359      *    them most efficiently. Therefore, during the first predraw pass, the listview
    360      *    must be offset by some amount such that given the custom bound change upon
    361      *    collapse, all the cells that need to be on the screen after the layout
    362      *    are rendered by the listview.
    363      * 3. On the second predraw pass, all the items are first returned to their original
    364      *    location (before the first layout).
    365      * 4. The collapsing view's bounds are animated to what the final values should be.
    366      * 5. The bounds above the collapsing view are animated downwards while the bounds
    367      *    below the collapsing view are animated upwards.
    368      * 6. The extra text is faded out as its contents become visible throughout the
    369      *    animation process.
    370      */
    371 
    372      private void collapseView(final View view) {
    373         final ExpandableListItem viewObject = (ExpandableListItem)getItemAtPosition
    374                 (getPositionForView(view));
    375 
    376         /* Store the original top and bottom bounds of all the cells.*/
    377         final int oldTop = view.getTop();
    378         final int oldBottom = view.getBottom();
    379 
    380         final HashMap<View, int[]> oldCoordinates = new HashMap<View, int[]>();
    381 
    382         int childCount = getChildCount();
    383         for (int i = 0; i < childCount; i++) {
    384             View v = getChildAt(i);
    385             v.setHasTransientState(true);
    386             oldCoordinates.put(v, new int [] {v.getTop(), v.getBottom()});
    387         }
    388 
    389         /* Update the layout so the extra content becomes invisible.*/
    390         view.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT,
    391                  viewObject.getCollapsedHeight()));
    392 
    393          /* Add an onPreDraw listener. */
    394         final ViewTreeObserver observer = getViewTreeObserver();
    395         observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
    396 
    397             @Override
    398             public boolean onPreDraw() {
    399 
    400                 if (!mShouldRemoveObserver) {
    401                     /*Same as for expandingView, the parameters for setSelectionFromTop must
    402                     * be determined such that the necessary cells of the ListView are rendered
    403                     * and added to it.*/
    404                     mShouldRemoveObserver = true;
    405 
    406                     int newTop = view.getTop();
    407                     int newBottom = view.getBottom();
    408 
    409                     int newHeight = newBottom - newTop;
    410                     int oldHeight = oldBottom - oldTop;
    411                     int deltaHeight = oldHeight - newHeight;
    412 
    413                     mTranslate = getTopAndBottomTranslations(oldTop, oldBottom, deltaHeight, false);
    414 
    415                     int currentTop = view.getTop();
    416                     int futureTop = oldTop + mTranslate[0];
    417 
    418                     int firstChildStartTop = getChildAt(0).getTop();
    419                     int firstVisiblePosition = getFirstVisiblePosition();
    420                     int deltaTop = currentTop - futureTop;
    421 
    422                     int i;
    423                     int childCount = getChildCount();
    424                     for (i = 0; i < childCount; i++) {
    425                         View v = getChildAt(i);
    426                         int height = v.getBottom() - Math.max(0, v.getTop());
    427                         if (deltaTop - height > 0) {
    428                             firstVisiblePosition++;
    429                             deltaTop -= height;
    430                         } else {
    431                             break;
    432                         }
    433                     }
    434 
    435                     if (i > 0) {
    436                         firstChildStartTop = 0;
    437                     }
    438 
    439                     setSelectionFromTop(firstVisiblePosition, firstChildStartTop - deltaTop);
    440 
    441                     requestLayout();
    442 
    443                     return false;
    444                 }
    445 
    446                 mShouldRemoveObserver = false;
    447                 observer.removeOnPreDrawListener(this);
    448 
    449                 int yTranslateTop = mTranslate[0];
    450                 int yTranslateBottom = mTranslate[1];
    451 
    452                 int index = indexOfChild(view);
    453                 int childCount = getChildCount();
    454                 for (int i = 0; i < childCount; i++) {
    455                     View v = getChildAt(i);
    456                     int [] old = oldCoordinates.get(v);
    457                     if (old != null) {
    458                         /* If the cell was present in the ListView before the collapse and
    459                         * after the collapse then the bounds are reset to their old values.*/
    460                         v.setTop(old[0]);
    461                         v.setBottom(old[1]);
    462                         v.setHasTransientState(false);
    463                     } else {
    464                         /* If the cell is present in the ListView after the collapse but
    465                          * not before the collapse then the bounds are calculated using
    466                          * the bottom and top translation of the collapsing cell.*/
    467                         int delta = i > index ? yTranslateBottom : -yTranslateTop;
    468                         v.setTop(v.getTop() + delta);
    469                         v.setBottom(v.getBottom() + delta);
    470                     }
    471                 }
    472 
    473                 final View expandingLayout = view.findViewById (R.id.expanding_layout);
    474 
    475                 /* Animates all the cells present on the screen after the collapse. */
    476                 ArrayList <Animator> animations = new ArrayList<Animator>();
    477                 for (int i = 0; i < childCount; i++) {
    478                     View v = getChildAt(i);
    479                     if (v != view) {
    480                         float diff = i > index ? -yTranslateBottom : yTranslateTop;
    481                         animations.add(getAnimation(v, diff, diff));
    482                     }
    483                 }
    484 
    485 
    486                 /* Adds animation for collapsing the cell that was clicked. */
    487                 animations.add(getAnimation(view, yTranslateTop, -yTranslateBottom));
    488 
    489                 /* Adds an animation for fading out the extra content. */
    490                 animations.add(ObjectAnimator.ofFloat(expandingLayout, View.ALPHA, 1, 0));
    491 
    492                 /* Disabled the ListView for the duration of the animation.*/
    493                 setEnabled(false);
    494                 setClickable(false);
    495 
    496                 /* Play all the animations created above together at the same time. */
    497                 AnimatorSet s = new AnimatorSet();
    498                 s.playTogether(animations);
    499                 s.addListener(new AnimatorListenerAdapter() {
    500                     @Override
    501                     public void onAnimationEnd(Animator animation) {
    502                         expandingLayout.setVisibility(View.GONE);
    503                         view.setLayoutParams(new AbsListView.LayoutParams(AbsListView
    504                                 .LayoutParams.MATCH_PARENT, AbsListView.LayoutParams.WRAP_CONTENT));
    505                         viewObject.setExpanded(false);
    506                         setEnabled(true);
    507                         setClickable(true);
    508                         /* Note that alpha must be set back to 1 in case this view is reused
    509                         * by a cell that was expanded, but not yet collapsed, so its state
    510                         * should persist in an expanded state with the extra content visible.*/
    511                         expandingLayout.setAlpha(1);
    512                     }
    513                 });
    514                 s.start();
    515 
    516                 return true;
    517             }
    518         });
    519     }
    520 
    521     /**
    522      * This method takes some view and the values by which its top and bottom bounds
    523      * should be changed by. Given these params, an animation which will animate
    524      * these bound changes is created and returned.
    525      */
    526     private Animator getAnimation(final View view, float translateTop, float translateBottom) {
    527 
    528         int top = view.getTop();
    529         int bottom = view.getBottom();
    530 
    531         int endTop = (int)(top + translateTop);
    532         int endBottom = (int)(bottom + translateBottom);
    533 
    534         PropertyValuesHolder translationTop = PropertyValuesHolder.ofInt("top", top, endTop);
    535         PropertyValuesHolder translationBottom = PropertyValuesHolder.ofInt("bottom", bottom,
    536                 endBottom);
    537 
    538         return ObjectAnimator.ofPropertyValuesHolder(view, translationTop, translationBottom);
    539     }
    540 }
    541