package com.hci.moola.view; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; import android.animation.TimeInterpolator; import android.content.Context; import android.graphics.Canvas; import android.util.AttributeSet; import android.view.View; import android.view.ViewTreeObserver; import android.view.animation.AccelerateInterpolator; import android.view.animation.DecelerateInterpolator; import android.widget.AbsListView; import android.widget.AbsListView.RecyclerListener; import android.widget.BaseAdapter; import android.widget.ListView; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; public class NaturalListView extends ListView implements RecyclerListener { private NaturalListViewListener mCallback; private List<View> mViewsToDraw = new ArrayList<View>(); private List<Animator> mAnimations = new ArrayList<Animator>(); private int[] mTranslate; private boolean mShouldRemoveObserver = false; public static TimeInterpolator DECEL_INTERPOLATOR = new DecelerateInterpolator(); public static TimeInterpolator ACCEL_INTERPOLATOR = new AccelerateInterpolator(); private static final boolean IS_JELLY_BEAN = true; public static final int ANIM_FADE_DURATION = 250; public static final int ANIM_EXPAND_DURATION = 250; public NaturalListView(Context context) { super(context); init(); } public NaturalListView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public NaturalListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } private void init() { setRecyclerListener(this); } public void onItemClick(View view, int position) { if (mCallback == null || !isEnabled() || (position = getPositionForView(view)) == INVALID_POSITION) return; ExpandableListItem viewObject = (ExpandableListItem) getItemAtPosition(position); if (viewObject.isExpanded()) { initCollapse(view, viewObject); } else { expandView(view, viewObject); } } public void setNaturalListViewListener(NaturalListViewListener listener) { mCallback = listener; } /** * Calculates the top and bottom bound changes of the selected item. These values are also used to move the bounds * of the items around the one that is actually being expanded or collapsed. * * This method can be modified to achieve different user experiences depending on how you want the cells to expand * or collapse. In this specific demo, the cells always try to expand downwards (leaving top bound untouched), and * similarly, collapse upwards (leaving top bound untouched). If the change in bounds results in the complete * disappearance of a cell, its lower bound is moved is moved to the top of the screen so as not to hide any * additional content that the user has not interacted with yet. Furthermore, if the collapsed cell is partially off * screen when it is first clicked, it is translated such that its full contents are visible. Lastly, this behaviour * varies slightly near the bottom of the listview in order to account for the fact that the bottom bounds of the * actual listview cannot be modified. */ private int[] getTopAndBottomTranslations(int top, int bottom, int yDelta, boolean isExpanding) { int yTranslateTop = 0; int yTranslateBottom = yDelta; int height = bottom - top; if (isExpanding) { boolean isOverTop = top < 0; boolean isBelowBottom = (top + height + yDelta) > getHeight(); if (isOverTop) { yTranslateTop = top; yTranslateBottom = yDelta - yTranslateTop; } else if (isBelowBottom) { int deltaBelow = top + height + yDelta - getHeight(); yTranslateTop = top - deltaBelow < 0 ? top : deltaBelow; yTranslateBottom = yDelta - yTranslateTop; } } else { if (doItemsFillListView()) { int offset = computeVerticalScrollOffset(); int range = computeVerticalScrollRange(); int extent = computeVerticalScrollExtent(); int leftoverExtent = range - offset - extent; boolean isCollapsingBelowBottom = (yTranslateBottom > leftoverExtent); boolean isCellCompletelyDisappearing = bottom - yTranslateBottom < 0; if (isCollapsingBelowBottom) { yTranslateTop = yTranslateBottom - leftoverExtent; yTranslateBottom = yDelta - yTranslateTop; } else if (isCellCompletelyDisappearing) { yTranslateBottom = bottom; yTranslateTop = yDelta - yTranslateBottom; } } else { yTranslateTop = 0; yTranslateBottom = yDelta; } } return new int[] { yTranslateTop, yTranslateBottom }; } private boolean doItemsFillListView() { if (getLastVisiblePosition() + 1 == getCount()) { return false; } return true; } /** * This method expands the view that was clicked and animates all the views around it to make room for the expanding * view. There are several steps required to do this which are outlined below. * * 1. Store the current top and bottom bounds of each visible item in the listview. 2. Update the layout parameters * of the selected view. In the context of this method, the view should be originally collapsed and set to some * custom height. The layout parameters are updated so as to wrap the content of the additional text that is to be * displayed. * * After invoking a layout to take place, the listview will order all the items such that there is space for each * view. This layout will be independent of what the bounds of the items were prior to the layout so two pre-draw * passes will be made. This is necessary because after the layout takes place, some views that were visible before * the layout may now be off bounds but a reference to these views is required so the animation completes as * intended. * * 3. The first predraw pass will set the bounds of all the visible items to their original location before the * layout took place and then force another layout. Since the bounds of the cells cannot be set directly, the method * setSelectionFromTop can be used to achieve a very similar effect. 4. The expanding view's bounds are animated to * what the final values should be from the original bounds. 5. The bounds above the expanding view are animated * upwards while the bounds below the expanding view are animated downwards. 6. The extra text is faded in as its * contents become visible throughout the animation process. * * It is important to note that the listview is disabled during the animation because the scrolling behaviour is * unpredictable if the bounds of the items within the listview are not constant during the scroll. */ private void expandView(final View view, final ExpandableListItem viewObject) { /* Store the original top and bottom bounds of all the cells. */ final int oldTop = view.getTop(); final int oldBottom = view.getBottom(); viewObject.setCollapsedHeight(oldBottom - oldTop); final HashMap<View, int[]> oldCoordinates = new HashMap<View, int[]>(); mAnimations.clear(); int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View v = getChildAt(i); if (IS_JELLY_BEAN) v.setHasTransientState(true); oldCoordinates.put(v, new int[] { v.getTop(), v.getBottom() }); } // Ask someone else to update the clicked view. if (!mCallback.onExpandStart(view, viewObject)) { return; } /* * Add an onPreDraw Listener to the listview. onPreDraw will get invoked after onLayout and onMeasure have run * but before anything has been drawn. This means that the final post layout properties for all the items have * already been determined, but still have not been rendered onto the screen. */ final ViewTreeObserver observer = getViewTreeObserver(); observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { /* Determine if this is the first or second pass. */ if (!mShouldRemoveObserver) { mShouldRemoveObserver = true; /* * Calculate what the parameters should be for setSelectionFromTop. The ListView must be offset in a * way, such that after the animation takes place, all the cells that remain visible are rendered * completely by the ListView. */ int newTop = view.getTop(); int newBottom = view.getBottom(); int newHeight = newBottom - newTop; int oldHeight = oldBottom - oldTop; int delta = newHeight - oldHeight; mTranslate = getTopAndBottomTranslations(oldTop, oldBottom, delta, true); int currentTop = view.getTop(); int futureTop = oldTop - mTranslate[0]; int firstChildStartTop = getChildAt(0).getTop(); int firstVisiblePosition = getFirstVisiblePosition(); int deltaTop = currentTop - futureTop; int i; int childCount = getChildCount(); for (i = 0; i < childCount; i++) { View v = getChildAt(i); int height = v.getBottom() - Math.max(0, v.getTop()); if (deltaTop - height > 0) { firstVisiblePosition++; deltaTop -= height; } else { break; } } if (i > 0) { firstChildStartTop = 0; } setSelectionFromTop(firstVisiblePosition, firstChildStartTop - deltaTop); /* * Request another layout to update the layout parameters of the cells. */ requestLayout(); /* * Return false such that the ListView does not redraw its contents on this layout but only updates * all the parameters associated with its children. */ return false; } /* * Remove the predraw listener so this method does not keep getting called. */ mShouldRemoveObserver = false; observer.removeOnPreDrawListener(this); int yTranslateTop = mTranslate[0]; int yTranslateBottom = mTranslate[1]; int index = indexOfChild(view); /* * Loop through all the views that were on the screen before the cell was expanded. Some cells will * still be children of the ListView while others will not. The cells that remain children of the * ListView simply have their bounds animated appropriately. The cells that are no longer children of * the ListView also have their bounds animated, but must also be added to a list of views which will be * drawn in dispatchDraw. */ for (View v : oldCoordinates.keySet()) { int[] old = oldCoordinates.get(v); v.setTop(old[0]); v.setBottom(old[1]); if (v.getParent() == null) { mViewsToDraw.add(v); int delta = old[0] < oldTop ? -yTranslateTop : yTranslateBottom; Animator anim = getAnimation(v, delta, delta); mAnimations.add(anim); } else { int i = indexOfChild(v); if (v != view) { int delta = i > index ? yTranslateBottom : -yTranslateTop; Animator anim = getAnimation(v, delta, delta); mAnimations.add(anim); } if (IS_JELLY_BEAN) v.setHasTransientState(false); } } /* Adds animation for expanding the cell that was clicked. */ mAnimations.add(getAnimation(view, -yTranslateTop, yTranslateBottom)); /* Adds an animation for fading in the extra content. */ Collection<Animator> fadeAnims = mCallback.addExpandAnimations(view, viewObject); if (fadeAnims != null) { for (Animator anim : fadeAnims) { mAnimations.add(anim); } } /* Disabled the ListView for the duration of the animation. */ setEnabled(false); setClickable(false); /* * Play all the animations created above together at the same time. */ AnimatorSet s = new AnimatorSet(); s.playTogether(mAnimations); s.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { viewObject.setExpanded(true); setEnabled(true); setClickable(true); if (mViewsToDraw.size() > 0) { for (View v : mViewsToDraw) { if (IS_JELLY_BEAN) v.setHasTransientState(false); } } ((BaseAdapter) getAdapter()).notifyDataSetChanged(); mViewsToDraw.clear(); mAnimations.clear(); /* Make any view changes after the expand ends */ mCallback.onExpandEnd(view, viewObject); } }); s.start(); return true; } }); } /** * By overriding dispatchDraw, we can draw the cells that disappear during the expansion process. When the cell * expands, some items below or above the expanding cell may be moved off screen and are thus no longer children of * the ListView's layout. By storing a reference to these views prior to the layout, and guaranteeing that these * cells do not get recycled, the cells can be drawn directly onto the canvas during the animation process. After * the animation completes, the references to the extra views can then be discarded. */ @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); if (mViewsToDraw.size() == 0) { return; } for (View v : mViewsToDraw) { canvas.translate(getPaddingLeft(), v.getTop()); v.draw(canvas); canvas.translate(-getPaddingLeft(), -v.getTop()); } } private void initCollapse(final View view, final ExpandableListItem viewObject) { Collection<Animator> childAnims = mCallback.addCollapseAnimations(view, viewObject); AnimatorSet s = new AnimatorSet(); s.playTogether(childAnims); s.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { collapseView(view, viewObject); animation.removeListener(this); } }); s.start(); } /** * This method collapses the view that was clicked and animates all the views around it to close around the * collapsing view. There are several steps required to do this which are outlined below. * * 1. Update the layout parameters of the view clicked so as to minimize its height to the original collapsed * (default) state. 2. After invoking a layout, the listview will shift all the cells so as to display them most * efficiently. Therefore, during the first predraw pass, the listview must be offset by some amount such that given * the custom bound change upon collapse, all the cells that need to be on the screen after the layout are rendered * by the listview. 3. On the second predraw pass, all the items are first returned to their original location * (before the first layout). 4. The collapsing view's bounds are animated to what the final values should be. 5. * The bounds above the collapsing view are animated downwards while the bounds below the collapsing view are * animated upwards. 6. The extra text is faded out as its contents become visible throughout the animation process. */ private void collapseView(final View view, final ExpandableListItem viewObject) { /* Store the original top and bottom bounds of all the cells. */ final int oldTop = view.getTop(); final int oldBottom = view.getBottom(); final HashMap<View, int[]> oldCoordinates = new HashMap<View, int[]>(); mAnimations.clear(); int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View v = getChildAt(i); if (IS_JELLY_BEAN) v.setHasTransientState(true); oldCoordinates.put(v, new int[] { v.getTop(), v.getBottom() }); } /* Make any view changes before the collapse begins */ mCallback.onCollapseStart(view, viewObject); /* Update the layout so the extra content becomes invisible. */ view.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, viewObject .getCollapsedHeight())); /* Add an onPreDraw listener. */ final ViewTreeObserver observer = getViewTreeObserver(); observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { if (!mShouldRemoveObserver) { /* * Same as for expandingView, the parameters for setSelectionFromTop must be determined such that * the necessary cells of the ListView are rendered and added to it. */ mShouldRemoveObserver = true; int newTop = view.getTop(); int newBottom = view.getBottom(); int newHeight = newBottom - newTop; int oldHeight = oldBottom - oldTop; int deltaHeight = oldHeight - newHeight; mTranslate = getTopAndBottomTranslations(oldTop, oldBottom, deltaHeight, false); int currentTop = view.getTop(); int futureTop = oldTop + mTranslate[0]; int firstChildStartTop = getChildAt(0).getTop(); int firstVisiblePosition = getFirstVisiblePosition(); int deltaTop = currentTop - futureTop; int i; int childCount = getChildCount(); for (i = 0; i < childCount; i++) { View v = getChildAt(i); int height = v.getBottom() - Math.max(0, v.getTop()); if (deltaTop - height > 0) { firstVisiblePosition++; deltaTop -= height; } else { break; } } if (i > 0) { firstChildStartTop = 0; } setSelectionFromTop(firstVisiblePosition, firstChildStartTop - deltaTop); requestLayout(); return false; } mShouldRemoveObserver = false; observer.removeOnPreDrawListener(this); int yTranslateTop = mTranslate[0]; int yTranslateBottom = mTranslate[1]; int index = indexOfChild(view); int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View v = getChildAt(i); int[] old = oldCoordinates.get(v); if (old != null) { /* * If the cell was present in the ListView before the collapse and after the collapse then the * bounds are reset to their old values. */ v.setTop(old[0]); v.setBottom(old[1]); if (IS_JELLY_BEAN) v.setHasTransientState(false); } else { /* * If the cell is present in the ListView after the collapse but not before the collapse then * the bounds are calculated using the bottom and top translation of the collapsing cell. */ int delta = i > index ? yTranslateBottom : -yTranslateTop; v.setTop(v.getTop() + delta); v.setBottom(v.getBottom() + delta); } } /* * Animates all the cells present on the screen after the collapse. */ for (int i = 0; i < childCount; i++) { View v = getChildAt(i); if (v != view) { float diff = i > index ? -yTranslateBottom : yTranslateTop; mAnimations.add(getAnimation(v, diff, diff)); } } /* Adds animation for collapsing the cell that was clicked. */ mAnimations.add(getAnimation(view, yTranslateTop, -yTranslateBottom)); /* Disabled the ListView for the duration of the animation. */ setEnabled(false); setClickable(false); /* * Play all the animations created above together at the same time. */ AnimatorSet s = new AnimatorSet(); s.playTogether(mAnimations); s.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { view.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, AbsListView.LayoutParams.WRAP_CONTENT)); mCallback.onCollapseEnd(view, viewObject); viewObject.setExpanded(false); setEnabled(true); setClickable(true); mAnimations.clear(); } }); s.start(); return true; } }); } /** * This method takes some view and the values by which its top and bottom bounds should be changed by. Given these * params, an animation which will animate these bound changes is created and returned. */ private Animator getAnimation(final View view, float translateTop, float translateBottom) { int top = view.getTop(); int bottom = view.getBottom(); int endTop = (int) (top + translateTop); int endBottom = (int) (bottom + translateBottom); PropertyValuesHolder translationTop = PropertyValuesHolder.ofInt("top", top, endTop); PropertyValuesHolder translationBottom = PropertyValuesHolder.ofInt("bottom", bottom, endBottom); return ObjectAnimator.ofPropertyValuesHolder(view, translationTop, translationBottom).setDuration( ANIM_EXPAND_DURATION); } public static interface NaturalListViewListener { /** * An item was clicked and should update its content. Make any necessary changes to show newly relevant * information. Only change the visibilities and sizes. Don't add any animations here. * * @param v * @param viewObject * @return true if the view has been updated and should be expanded. */ public boolean onExpandStart(View v, ExpandableListItem viewObject); public void onExpandEnd(View v, ExpandableListItem viewObject); public void onCollapseStart(View v, ExpandableListItem viewObject); public void onCollapseEnd(View v, ExpandableListItem viewObject); public Collection<Animator> addExpandAnimations(View v, ExpandableListItem viewObject); public Collection<Animator> addCollapseAnimations(View v, ExpandableListItem viewObject); } public static abstract class NaturalListViewAdapter implements NaturalListViewListener { @Override public boolean onExpandStart(View v, ExpandableListItem viewObject) { return false; } @Override public void onExpandEnd(View v, ExpandableListItem viewObject) { } @Override public void onCollapseStart(View v, ExpandableListItem viewObject) { } @Override public void onCollapseEnd(View v, ExpandableListItem viewObject) { } @Override public Collection<Animator> addExpandAnimations(View v, ExpandableListItem viewObject) { return null; } @Override public Collection<Animator> addCollapseAnimations(View v, ExpandableListItem viewObject) { return null; } } @Override public void onMovedToScrapHeap(View view) { if (IS_JELLY_BEAN) return; for (Animator a : mAnimations) a.cancel(); mAnimations.clear(); } }