1/*
2 * Copyright (C) 2015 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
17package com.android.messaging.ui.animation;
18
19import android.animation.TypeEvaluator;
20import android.app.Activity;
21import android.graphics.Canvas;
22import android.graphics.Color;
23import android.graphics.Rect;
24import android.view.Gravity;
25import android.view.View;
26import android.view.ViewGroup;
27import android.view.animation.Animation;
28import android.view.animation.Transformation;
29import android.widget.PopupWindow;
30
31import com.android.messaging.util.LogUtil;
32import com.android.messaging.util.ThreadUtil;
33import com.android.messaging.util.UiUtils;
34
35/**
36 * Animates viewToAnimate from startRect to the place where it is in the layout,  viewToAnimate
37 * should be in its final destination location before startAfterLayoutComplete is called.
38 * viewToAnimate will be drawn scaled and offset in a popupWindow.
39 * This class handles the case where the viewToAnimate moves during the animation
40 */
41public class PopupTransitionAnimation extends Animation {
42    /** The view we're animating */
43    private final View mViewToAnimate;
44
45    /** The rect to start the slide in animation from */
46    private final Rect mStartRect;
47
48    /** The rect of the currently animated view */
49    private Rect mCurrentRect;
50
51    /** The rect that we're animating to.  This can change during the animation */
52    private final Rect mDestRect;
53
54    /** The bounds of the popup in window coordinates.  Does not include notification bar */
55    private final Rect mPopupRect;
56
57    /** The bounds of the action bar in window coordinates.  We clip the popup to below this */
58    private final Rect mActionBarRect;
59
60    /** Interpolates between the start and end rect for every animation tick */
61    private final TypeEvaluator<Rect> mRectEvaluator;
62
63    /** The popup window that holds contains the animating view */
64    private PopupWindow mPopupWindow;
65
66    /** The layout root for the popup which is where the animated view is rendered */
67    private View mPopupRoot;
68
69    /** The action bar's view */
70    private final View mActionBarView;
71
72    private Runnable mOnStartCallback;
73    private Runnable mOnStopCallback;
74
75    public PopupTransitionAnimation(final Rect startRect, final View viewToAnimate) {
76        mViewToAnimate = viewToAnimate;
77        mStartRect = startRect;
78        mCurrentRect = new Rect(mStartRect);
79        mDestRect = new Rect();
80        mPopupRect = new Rect();
81        mActionBarRect = new Rect();
82        final Activity activity = (Activity) viewToAnimate.getRootView().getContext();
83        mActionBarView = activity.getWindow().getDecorView().findViewById(
84                android.support.v7.appcompat.R.id.action_bar);
85        mRectEvaluator = RectEvaluatorCompat.create();
86        setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION);
87        setInterpolator(UiUtils.DEFAULT_INTERPOLATOR);
88        setAnimationListener(new AnimationListener() {
89            @Override
90            public void onAnimationStart(final Animation animation) {
91                if (mOnStartCallback != null) {
92                    mOnStartCallback.run();
93                }
94                mEvents.append("oAS,");
95            }
96
97            @Override
98            public void onAnimationEnd(final Animation animation) {
99                if (mOnStopCallback != null) {
100                    mOnStopCallback.run();
101                }
102                dismiss();
103                mEvents.append("oAE,");
104            }
105
106            @Override
107            public void onAnimationRepeat(final Animation animation) {
108            }
109        });
110    }
111
112    private final StringBuilder mEvents = new StringBuilder();
113    private final Runnable mCleanupRunnable = new Runnable() {
114        @Override
115        public void run() {
116            LogUtil.w(LogUtil.BUGLE_TAG, "PopupTransitionAnimation: " + mEvents);
117        }
118    };
119
120    /**
121     * Ensures the animation is ready before starting the animation.
122     * viewToAnimate must first be layed out so we know where we will animate to
123     */
124    public void startAfterLayoutComplete() {
125        // We want layout to occur, and then we immediately animate it in, so hide it initially to
126        // reduce jank on the first frame
127        mViewToAnimate.setVisibility(View.INVISIBLE);
128        mViewToAnimate.setAlpha(0);
129
130        final Runnable startAnimation = new Runnable() {
131            boolean mRunComplete = false;
132            boolean mFirstTry = true;
133
134            @Override
135            public void run() {
136                if (mRunComplete) {
137                    return;
138                }
139
140                mViewToAnimate.getGlobalVisibleRect(mDestRect);
141                // In Android views which are visible but haven't computed their size yet have a
142                // size of 1x1 because anything with a size of 0x0 is considered hidden.  We can't
143                // start the animation until after the size is greater than 1x1
144                if (mDestRect.width() <= 1 || mDestRect.height() <= 1) {
145                    // Layout hasn't occurred yet
146                    if (!mFirstTry) {
147                        // Give up if this is not the first try, since layout change still doesn't
148                        // yield a size for the view. This is likely because the media picker is
149                        // full screen so there's no space left for the animated view. We give up
150                        // on animation, but need to make sure the view that was initially
151                        // hidden is re-shown.
152                        mViewToAnimate.setAlpha(1);
153                        mViewToAnimate.setVisibility(View.VISIBLE);
154                    } else {
155                        mFirstTry = false;
156                        UiUtils.doOnceAfterLayoutChange(mViewToAnimate, this);
157                    }
158                    return;
159                }
160
161                mRunComplete = true;
162                mViewToAnimate.startAnimation(PopupTransitionAnimation.this);
163                mViewToAnimate.invalidate();
164                // http://b/20856505: The PopupWindow sometimes does not get dismissed.
165                ThreadUtil.getMainThreadHandler().postDelayed(mCleanupRunnable, getDuration() * 2);
166            }
167        };
168
169        startAnimation.run();
170    }
171
172    public PopupTransitionAnimation setOnStartCallback(final Runnable onStart) {
173        mOnStartCallback = onStart;
174        return this;
175    }
176
177    public PopupTransitionAnimation setOnStopCallback(final Runnable onStop) {
178        mOnStopCallback = onStop;
179        return this;
180    }
181
182    @Override
183    protected void applyTransformation(final float interpolatedTime, final Transformation t) {
184        if (mPopupWindow == null) {
185            initPopupWindow();
186        }
187        // Update mDestRect as it may have moved during the animation
188        mPopupRect.set(UiUtils.getMeasuredBoundsOnScreen(mPopupRoot));
189        mActionBarRect.set(UiUtils.getMeasuredBoundsOnScreen(mActionBarView));
190        computeDestRect();
191
192        // Update currentRect to the new animated coordinates, and request mPopupRoot to redraw
193        // itself at the new coordinates
194        mCurrentRect = mRectEvaluator.evaluate(interpolatedTime, mStartRect, mDestRect);
195        mPopupRoot.invalidate();
196
197        if (interpolatedTime >= 0.98) {
198            mEvents.append("aT").append(interpolatedTime).append(',');
199        }
200        if (interpolatedTime == 1) {
201            dismiss();
202        }
203    }
204
205    private void dismiss() {
206        mEvents.append("d,");
207        mViewToAnimate.setAlpha(1);
208        mViewToAnimate.setVisibility(View.VISIBLE);
209        // Delay dismissing the popup window to let mViewToAnimate draw under it and reduce the
210        // flash
211        ThreadUtil.getMainThreadHandler().post(new Runnable() {
212            @Override
213            public void run() {
214                try {
215                    mPopupWindow.dismiss();
216                } catch (IllegalArgumentException e) {
217                    // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity
218                    // has already ended while we were animating
219                }
220                ThreadUtil.getMainThreadHandler().removeCallbacks(mCleanupRunnable);
221            }
222        });
223    }
224
225    @Override
226    public boolean willChangeBounds() {
227        return false;
228    }
229
230    /**
231     * Computes mDestRect (the position in window space of the placeholder view that we should
232     * animate to).  Some frames during the animation fail to compute getGlobalVisibleRect, so use
233     * the last known values in that case
234     */
235    private void computeDestRect() {
236        final int prevTop = mDestRect.top;
237        final int prevLeft = mDestRect.left;
238        final int prevRight = mDestRect.right;
239        final int prevBottom = mDestRect.bottom;
240
241        if (!getViewScreenMeasureRect(mViewToAnimate, mDestRect)) {
242            mDestRect.top = prevTop;
243            mDestRect.left = prevLeft;
244            mDestRect.bottom = prevBottom;
245            mDestRect.right = prevRight;
246        }
247    }
248
249    /**
250     * Sets up the PopupWindow that the view will animate in.  Animating the size and position of a
251     * popup can be choppy, so instead we make the popup fill the entire space of the screen, and
252     * animate the position of viewToAnimate within the popup using a Transformation
253     */
254    private void initPopupWindow() {
255        mPopupRoot = new View(mViewToAnimate.getContext()) {
256            @Override
257            protected void onDraw(final Canvas canvas) {
258                canvas.save();
259                canvas.clipRect(getLeft(), mActionBarRect.bottom - mPopupRect.top, getRight(),
260                        getBottom());
261                canvas.drawColor(Color.TRANSPARENT);
262                final float previousAlpha = mViewToAnimate.getAlpha();
263                mViewToAnimate.setAlpha(1);
264                // The view's global position includes the notification bar height, but
265                // the popup window may or may not cover the notification bar (depending on screen
266                // rotation, IME status etc.), so we need to compensate for this difference by
267                // offseting vertically.
268                canvas.translate(mCurrentRect.left, mCurrentRect.top - mPopupRect.top);
269
270                final float viewWidth = mViewToAnimate.getWidth();
271                final float viewHeight = mViewToAnimate.getHeight();
272                if (viewWidth > 0 && viewHeight > 0) {
273                    canvas.scale(mCurrentRect.width() / viewWidth,
274                            mCurrentRect.height() / viewHeight);
275                }
276                canvas.clipRect(0, 0, mCurrentRect.width(), mCurrentRect.height());
277                if (!mPopupRect.isEmpty()) {
278                    // HACK: Layout is unstable until mPopupRect is non-empty.
279                    mViewToAnimate.draw(canvas);
280                }
281                mViewToAnimate.setAlpha(previousAlpha);
282                canvas.restore();
283            }
284        };
285        mPopupWindow = new PopupWindow(mViewToAnimate.getContext());
286        mPopupWindow.setBackgroundDrawable(null);
287        mPopupWindow.setContentView(mPopupRoot);
288        mPopupWindow.setWidth(ViewGroup.LayoutParams.MATCH_PARENT);
289        mPopupWindow.setHeight(ViewGroup.LayoutParams.MATCH_PARENT);
290        mPopupWindow.setTouchable(false);
291        // We must pass a non-zero value for the y offset, or else the system resets the status bar
292        // color to black (M only) during the animation. The actual position of the window (and
293        // the animated view inside it) are still correct, regardless of what we pass for the y
294        // parameter (e.g. 1 and 100 both work). Not entirely sure why this works.
295        mPopupWindow.showAtLocation(mViewToAnimate, Gravity.TOP, 0, 1);
296    }
297
298    private static boolean getViewScreenMeasureRect(final View view, final Rect outRect) {
299        outRect.set(UiUtils.getMeasuredBoundsOnScreen(view));
300        return !outRect.isEmpty();
301    }
302}
303