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 
ExpandingListView(Context context)49     public ExpandingListView(Context context) {
50         super(context);
51         init();
52     }
53 
ExpandingListView(Context context, AttributeSet attrs)54     public ExpandingListView(Context context, AttributeSet attrs) {
55         super(context, attrs);
56         init();
57     }
58 
ExpandingListView(Context context, AttributeSet attrs, int defStyle)59     public ExpandingListView(Context context, AttributeSet attrs, int defStyle) {
60         super(context, attrs, defStyle);
61         init();
62     }
63 
init()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      */
getTopAndBottomTranslations(int top, int bottom, int yDelta, boolean isExpanding)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