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