Tuesday, March 12, 2013

Animated removal of list items

Task to do: we have a ListView in an Android layout. Whenever an item is removed, the list item flies out from its original position to the top of the ListView.

Android has predefined animations for adding and removing items from a ViewGroup. Even more, you can define animation for the other elements (that are not being removed), when some sibling element is being removed. See: http://developer.android.com/training/animation/layout.html . However a ListView is a ViewGroup, this animation never happens, because the adapter of the ListView is immediately updates the view after a removal. You can set animation for the listItems and start them eg. during addition, but not during removal.

There are a couple of ways to make it work:
  • Seaching google, many suggest to utilise onListItemClick listener, and start animation than (eg. http://stackoverflow.com/questions/3928193/how-to-animate-addition-or-removal-of-android-listview-rows). And at the same time schedule a runnable with postDelayed(). I don't believe, relying on times schedules is a good idea generally, even though we might know the exact value of the delay (with Animation.getDuration() in this case). It still can be unprecise.
  • A bit improvement is to use setAnimationListener to remove the item exactly at the end of the animation. Problem in my case is, the event of the removal of the item comes from the model (and view should not modify its data, that should be a copy of the model, or else synch issues may raise). So the onListItemClick is not involved in this process at all.
  • Implement a custom ListView. (but if for each problem I have to implement a custom class, what the Android framework is good for?)
  • My approach is to initiate an animation from the Adapter (that receives update messages from the model). Unfortunately it will have a small visual flaw: the other list items will jump to their new space without animation.
    1. The adapter inflates a new view (getView() is visible from there). Actually it is weird the adapter doesn't have reference to the ui element, it controls, so we can't get the view itself by default (however we could set this reference manually).
    2. places it to the same position where the removable item was,
    3. starts a custom animation
    4. and at the same time it can remove the item.
A fairly good post about interpolators is here, to show how each interpolator works.

The adapter starts the animation when an item is removed.
class MyAdapter extends ArrayAdapter<WhateverListItem> {
// ... removed some code for clarity

    /** Animation used when an item is removed from the adapter. Could be set by the constructor */
    private RemoveAnimation mRemoveAnimation;

    /**
     * Sets the remove animation. This could be done in the constructor, but in my project, this method was more appropriate
     */
    public void setRemoveAnimation(RemoveAnimation removeAnimation) {
        mRemoveAnimation = removeAnimation;
    }

    @Override
    public void remove(SpendingRewardItem object) {
        final View view = mVisibleItems.get(object.mOfferId);
        if (view != null) { // we only want to play the animation for visible items. This mVisibleItems set is maintained by us
            final View removedView = getView(getPosition(object), null, null); // create a copy of the removed item
            if (mRemoveAnimation != null) mRemoveAnimation.animate(view, removedView); // play the animation
            mVisibleItems.remove(object.mOfferId);
        }
        super.remove(object);
    }

    /**
     * Tracks which item is visible currently. Used to test if a removed item should be animated.
     */
    private WeakHashMap<String, View> mVisibleItems = new WeakHashMap<String, View>();

    /**
     * ListAdapter override.
     * @param position item position in the parents' inner list
     * @param convertView reused view if any
     * @param parent parent of the view
     * @return inflated view
     */
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        final View view = super.getView(position, convertView, parent);

        // ... customize view, holder pattern, etc.

        mVisibleItems.put(getItem(position).mOfferId, view);
        return view;
    }

}

The animation itself. I use the end of animation to initiate an other one (described in a previous post), I leave related lines here commented out. There are some secret additions. The animated item can go out of the original container. It plays multiple animations on the same item. It is important, that this translates only vertically. Horizontally the item will stay where it was (in my case it will stay in the middle).
public class RemoveAnimation {

//    private static final int GLOW_ANIM_DURATION = 500;
    /** This will be the root ViewGroup  for the animated item. With this, the view can "fly out" of the original ListView */
    private RelativeLayout mAnimationRoot;
    /** Used to acquire interpolators */
    private Context mContext;
//    private final View mTargetTab;
//    private TabGlow mGlow;

    /** In this implementation, animationRoot has to be RelativeLayout, because animate() uses its typed LayoutParams */
    public SpendingRewardsRemoveAnimation(RelativeLayout animationRoot, View targetTab) {
        mAnimationRoot = animationRoot;
        mContext = animationRoot.getContext();
//        mTargetTab = targetTab;
//        mGlow = new TabGlow();
    }

    public void animate(View originalView, View copyView) {
        if (originalView != null && copyView != null) {

            final View animatingView;
            animatingView = copyView;
            // place the copyView to where the originalView was.
            final RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(originalView.getWidth(), originalView.getHeight());
            layoutParams.leftMargin = originalView.getLeft();
            layoutParams.topMargin = originalView.getTop() + getDistanceFromRoot(originalView);
            animatingView.setLayoutParams(layoutParams);

// you need to set the background colour of the list items, or else they will have transparent background. Which is weird usually.
//            animatingView.setBackgroundColor(Color.argb(255, 14, 52, 128));
            mAnimationRoot.addView(animatingView);
            mAnimationRoot.invalidate(); // ask the framework to redraw this view


            // create scale animations. You can play with these parameters. Check reference
            final ScaleAnimation scaleUp = new ScaleAnimation(1.0f, 1.1f, 1.0f, 1.1f, Animation.RELATIVE_TO_SELF,0.5f, Animation.RELATIVE_TO_SELF, 0f);
            final ScaleAnimation scaleDown = new ScaleAnimation(1.0f, 0.0f, 1.0f, 0.0f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0f);
            scaleUp.setDuration(50);
            scaleUp.setInterpolator(mContext, android.R.anim.decelerate_interpolator);
            scaleDown.setDuration(250);
            scaleDown.setInterpolator(mContext, android.R.anim.accelerate_interpolator);
            scaleDown.setStartOffset(50); // second animation starts, when firsts finishes

            // create translation animation
            final TranslateAnimation trans = new TranslateAnimation(
                    Animation.RELATIVE_TO_SELF,
                    0,
                    Animation.RELATIVE_TO_SELF,
                    0,
                    Animation.ABSOLUTE,
                    0,
                    Animation.ABSOLUTE,
                    -1.0f * layoutParams.topMargin // move the item from the current position upwards, to 0 (finally topMargin + (-1 * topMargin) will be equal to 0
            );
            trans.setDuration(250);
            trans.setStartOffset(50);
            trans.setInterpolator(mContext, android.R.anim.accelerate_interpolator);

            // add new animations to the set
            final AnimationSet animationSet = new AnimationSet(false);
            animationSet.addAnimation(scaleUp); // view first pops up
            animationSet.addAnimation(scaleDown); // a bit later shrinks to nothing
            animationSet.addAnimation(trans); // and flies to the top of 

            animationSet.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
                    animatingView.setVisibility(View.VISIBLE);
                }

                @Override
                public void onAnimationEnd(Animation animation) {
                    animatingView.setVisibility(View.GONE);
                    mAnimationRoot.post(new Runnable() {
                        @Override
                        public void run() {
                            mAnimationRoot.removeView(animatingView); // we don't want to keep the animated View forever

                        }
                    });


//                    mGlow.glow(mTargetTab, GLOW_ANIM_DURATION);
                }

                @Override
                public void onAnimationRepeat(Animation animation) {
                }
            });
            animatingView.startAnimation(animationSet);

        }
    }

    /** This calculates the absolute position of the view. Which can differ from getTop() when the any parent container also has a vertical displacement. */
    private int getDistanceFromRoot(View originalView) {
        View current = originalView;
        ViewGroup parent = null;
        int ret = 0;

        do {
            if (current.getParent() instanceof ViewGroup) {
                parent = (ViewGroup)current.getParent();
                ret += parent.getTop();
                current = parent;
            }
        } while (parent != null && parent != mAnimationRoot);
        return ret;
    }
}

Wiring up is easy:
class MyFragment extends Fragment {

// removed some code for brevity

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        final View inflated = inflater.inflate(R.layout.spending_rewards_hub, null);

        final ListView listView = (ListView) inflated.findViewById(R.id.listView);
        mAdapter = new MyAdapter(/* whatever you initialise with */);
        mAdapter.setRemoveAnimation(new SpendingRewardsRemoveAnimation((RelativeLayout)inflated, activeTab));
        listView.setAdapter(mAdapter);

        // ... more UI initialisation as needed
    }
}


No comments: