воскресенье, 6 сентября 2015 г.

Making fast performance complex animations in android

Recently, I've ran into a problem with animating item's height inside a list for some devices with poor performance. And I've done a library for that. It animates RecyclerView or ListView item's height, but isn't heavy-bounded to that containers - see on github. That animation is also known as expand-collapse animation. Here I describe main problems and solutions founded so far, which is used in library. Library is finished and I'll be very glad if you find some issues or propose pull requests.

So, let's start. To be more clear, I'll write some straightforward code which does what I'm telling about:

ValueAnimator animator = ValueAnimator.ofInt(100, 300);
animator.setDuration(1000);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  @Override
  public void onAnimationUpdate(ValueAnimator animation) {
    listViewItem.getLayoutParams().height = (Integer) animation.getAnimatedValue();
    listViewItem.requestLayout();
  }
});
animator.start();
Many libraries, I so before, use this technique. But that is heavy because of requestLayout() - it causes layout and measure steps repeated again each call. From the other side,
it has it's rights for living - android developers already introduces nice LayoutTransition which does it's job using exactly same technique: the layout is animated, so layout and measure steps are done again and again until animation finish. That's the only way making CHANGE_APPEARING and CHANGE_DISAPPEARING work when animating for example from layout with items AC to layout with items ABC (C must go down with animating and recalculating everything around to keep layout constraints). So, the following must be considered performance hack. Which may be useful to know when making simple animations.

So, here are some key thoughts:

1. So, all we know, that following animations are fast:
a. Alpha
b. Scale
c. Translate
d. Rotate

2. To change items with just translation and not repositioning other items we can make items heigher than they are so that each next item will hide bottom part of the previos one. They will be like game cards - each one above the other but not fully to see what card is below. This can be achived in many ways:
a. negative bottom margin
b. simply make a wrapper for view which will hold item and clip. FrameLayout does that by default

3. That's not all. Because items are still there. We must care about touch dispatching. Suppose we are clicking on item to expand it. After expand it will be above the previos. We click again for some reason and there is a button on the previos card which steals touch event! User even don't see it, because it is below, but android doesn't use only item's visibility when dispatching touch events. We override dispatchTouchEvent() of item which is animating. So we do simple wrapper for each list item which does the work for us:

    public class VerticalClipLayout extends FrameLayout {
    private float expandCoef = 0; // collapsed by default
    private float clipCoef = 1; // so, full clipping of animated part

    public VerticalClipLayout(Context context) {
        super(context);
        initialize();
    }

    public VerticalClipLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialize();
    }

    public VerticalClipLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize();
    }

    @TargetApi(21)
    public VerticalClipLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        initialize();
    }

    private void initialize() {
        setWillNotDraw(false);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        // do not dispatch touch events to view or it's children when clicking the clipped area
        return ev.getY() < getClippedHeight() && super.dispatchTouchEvent(ev);
    }

    // In xml layout_height of VerticalClipLayout is the collapsed height. Inside VerticalClipLayout
    // must be exactly one child view/layout. It's height will be the expanded height.
    // So, setting layout_height="match_parent" for child view is useless.
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if(getChildCount() == 1) {
            View child = getChildAt(0);

            super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(child.getLayoutParams().height, MeasureSpec.getMode(heightMeasureSpec)));
            MarginLayoutParams margins = (MarginLayoutParams)getLayoutParams();
            margins.bottomMargin = MeasureSpec.getSize(heightMeasureSpec) - child.getMeasuredHeight();
        } else {
            throw new IllegalArgumentException("VerticalClipLayout should have exactly 1 child");
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // clipping bottom part to force see video beneath
        canvas.clipRect(0, 0, getWidth(), getClippedHeight()); //Region.Op.REPLACE is no use because some applied clippings (for example from action bar) are not applied sometimes with REPLACE
        super.onDraw(canvas);
    }

    // set's current expand in 0..1 range
    // expandCoef is the coef to expand - y changes when expanding
    public void setExpandCoef(float expandCoef) {
        if(0 > expandCoef || 1 < expandCoef) {
            throw new IllegalArgumentException("expandCoef is out of range [0, 1], expandCoef: " + expandCoef);
        }
        this.expandCoef = expandCoef;

        setTranslationY(expandCoef * ((MarginLayoutParams) getLayoutParams()).bottomMargin);
    }

    // clipHeightCoef is in range 0..1, where 1 means clip all the animated area and show only the allways shown part at the top. 0 means show everything, you'll see fully expaded view
    public void setClipCoef(float clipHeightCoef) {
        if(0 > clipHeightCoef || 1 < clipHeightCoef) {
            throw new IllegalArgumentException("clipHeightCoef is out of range [0, 1], clipHeightCoef: " + clipHeightCoef);
        }
        clipCoef = clipHeightCoef;
        invalidate();
    }

    public float getExpandCoef() {
        return expandCoef;
    }

    // returns y, which view would have, if setExpandCoef(1) was called
    public float getYWhenExpanded() {
        return super.getTop() + ((MarginLayoutParams) getLayoutParams()).bottomMargin;
    }

    public int getExpandedHeight() {
        return getHeight();
    }

    public float getClippedHeight() {
        return getHeight() + clipCoef * ((MarginLayoutParams) getLayoutParams()).bottomMargin;
    }
}
4. I also like that wrapping view, because of encapsulating actual implementation (negative bottom margin or whatever) inside that class to not mess things around.

5. Make an animator class, which just helps us control some ValueAnimator
public class ExpandCollapseAnimator implements ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {

    public ExpandCollapseAnimator(float speed);
    public void setOnViewExpandCollapseListener(OnViewExpandCollapseListener listener);

    //call in onResume()
    public void start();

    //call in onPause()
    public void pause();

    // adds view to processing
    public void add(int position, VerticalClipLayout view);

    public void remove(int position);

    // start expand animation for view. Note, that it is neccessary for view to be add()'ed before that call
    public void setExpanding(int position);

    public interface OnViewExpandCollapseListener {
        void onViewStartExpanding(int position, VerticalClipLayout v);
        void onViewExpanded(int position, VerticalClipLayout v);
        void onViewStartCollapsing(int position, VerticalClipLayout v);
        void onViewsChanging();
    }
}

Note: that implementation is compatible with both ListView and RecyclerView. And it has no problems with not animating footer. Thats example:
recyclerView.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() {
            @Override
            public void onChildViewAdded(View parent, final View child) {
                if(recyclerView.getChildViewHolder(child).getItemViewType() != PhoneCardAdapter.VIEW_TYPE_CARD) {
                    return; // just don't add it to our animator
                }
                child.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
                    @Override
                    public boolean onPreDraw() {
                        child.getViewTreeObserver().removeOnPreDrawListener(this);

                        int position = recyclerView.getChildAdapterPosition(child);
                        animator.add(position, (VerticalClipLayout) child);
                        return false;
                    }
                });
            }

            @Override
            public void onChildViewRemoved(View parent, View child) {
                if(recyclerView.getChildViewHolder(child).getItemViewType() != PhoneCardAdapter.VIEW_TYPE_CARD) {
                    return;
                }
                animator.remove(position);
            }
        });

6. Almost forgot, using OnPreDrawListener() we just assure, that item is positioned, scaled, etc. properly so we can take that things and animate properly.

All this is done as library available with example usage on github

Комментариев нет:

Отправить комментарий