1/*
2 * Copyright (C) 2012 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
18package com.android.systemui;
19
20import android.animation.Animator;
21import android.animation.AnimatorListenerAdapter;
22import android.animation.ObjectAnimator;
23import android.content.Context;
24import android.util.Log;
25import android.view.Gravity;
26import android.view.HapticFeedbackConstants;
27import android.view.MotionEvent;
28import android.view.ScaleGestureDetector;
29import android.view.ScaleGestureDetector.OnScaleGestureListener;
30import android.view.VelocityTracker;
31import android.view.View;
32import android.view.ViewConfiguration;
33
34import com.android.internal.annotations.VisibleForTesting;
35import com.android.systemui.statusbar.ExpandableNotificationRow;
36import com.android.systemui.statusbar.ExpandableView;
37import com.android.systemui.statusbar.FlingAnimationUtils;
38import com.android.systemui.statusbar.policy.ScrollAdapter;
39
40public class ExpandHelper implements Gefingerpoken {
41    public interface Callback {
42        ExpandableView getChildAtRawPosition(float x, float y);
43        ExpandableView getChildAtPosition(float x, float y);
44        boolean canChildBeExpanded(View v);
45        void setUserExpandedChild(View v, boolean userExpanded);
46        void setUserLockedChild(View v, boolean userLocked);
47        void expansionStateChanged(boolean isExpanding);
48        int getMaxExpandHeight(ExpandableView view);
49        void setExpansionCancelled(View view);
50    }
51
52    private static final String TAG = "ExpandHelper";
53    protected static final boolean DEBUG = false;
54    protected static final boolean DEBUG_SCALE = false;
55    private static final float EXPAND_DURATION = 0.3f;
56
57    // Set to false to disable focus-based gestures (spread-finger vertical pull).
58    private static final boolean USE_DRAG = true;
59    // Set to false to disable scale-based gestures (both horizontal and vertical).
60    private static final boolean USE_SPAN = true;
61    // Both gestures types may be active at the same time.
62    // At least one gesture type should be active.
63    // A variant of the screwdriver gesture will emerge from either gesture type.
64
65    // amount of overstretch for maximum brightness expressed in U
66    // 2f: maximum brightness is stretching a 1U to 3U, or a 4U to 6U
67    private static final float STRETCH_INTERVAL = 2f;
68
69    @SuppressWarnings("unused")
70    private Context mContext;
71
72    private boolean mExpanding;
73    private static final int NONE    = 0;
74    private static final int BLINDS  = 1<<0;
75    private static final int PULL    = 1<<1;
76    private static final int STRETCH = 1<<2;
77    private int mExpansionStyle = NONE;
78    private boolean mWatchingForPull;
79    private boolean mHasPopped;
80    private View mEventSource;
81    private float mOldHeight;
82    private float mNaturalHeight;
83    private float mInitialTouchFocusY;
84    private float mInitialTouchX;
85    private float mInitialTouchY;
86    private float mInitialTouchSpan;
87    private float mLastFocusY;
88    private float mLastSpanY;
89    private int mTouchSlop;
90    private float mLastMotionY;
91    private float mPullGestureMinXSpan;
92    private Callback mCallback;
93    private ScaleGestureDetector mSGD;
94    private ViewScaler mScaler;
95    private ObjectAnimator mScaleAnimation;
96    private boolean mEnabled = true;
97    private ExpandableView mResizedView;
98    private float mCurrentHeight;
99
100    private int mSmallSize;
101    private int mLargeSize;
102    private float mMaximumStretch;
103    private boolean mOnlyMovements;
104
105    private int mGravity;
106
107    private ScrollAdapter mScrollAdapter;
108    private FlingAnimationUtils mFlingAnimationUtils;
109    private VelocityTracker mVelocityTracker;
110
111    private OnScaleGestureListener mScaleGestureListener
112            = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
113        @Override
114        public boolean onScaleBegin(ScaleGestureDetector detector) {
115            if (DEBUG_SCALE) Log.v(TAG, "onscalebegin()");
116
117            if (!mOnlyMovements) {
118                startExpanding(mResizedView, STRETCH);
119            }
120            return mExpanding;
121        }
122
123        @Override
124        public boolean onScale(ScaleGestureDetector detector) {
125            if (DEBUG_SCALE) Log.v(TAG, "onscale() on " + mResizedView);
126            return true;
127        }
128
129        @Override
130        public void onScaleEnd(ScaleGestureDetector detector) {
131        }
132    };
133
134    @VisibleForTesting
135    ObjectAnimator getScaleAnimation() {
136        return mScaleAnimation;
137    }
138
139    private class ViewScaler {
140        ExpandableView mView;
141
142        public ViewScaler() {}
143        public void setView(ExpandableView v) {
144            mView = v;
145        }
146        public void setHeight(float h) {
147            if (DEBUG_SCALE) Log.v(TAG, "SetHeight: setting to " + h);
148            mView.setActualHeight((int) h);
149            mCurrentHeight = h;
150        }
151        public float getHeight() {
152            return mView.getActualHeight();
153        }
154        public int getNaturalHeight() {
155            return mCallback.getMaxExpandHeight(mView);
156        }
157    }
158
159    /**
160     * Handle expansion gestures to expand and contract children of the callback.
161     *
162     * @param context application context
163     * @param callback the container that holds the items to be manipulated
164     * @param small the smallest allowable size for the manuipulated items.
165     * @param large the largest allowable size for the manuipulated items.
166     */
167    public ExpandHelper(Context context, Callback callback, int small, int large) {
168        mSmallSize = small;
169        mMaximumStretch = mSmallSize * STRETCH_INTERVAL;
170        mLargeSize = large;
171        mContext = context;
172        mCallback = callback;
173        mScaler = new ViewScaler();
174        mGravity = Gravity.TOP;
175        mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f);
176        mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min);
177
178        final ViewConfiguration configuration = ViewConfiguration.get(mContext);
179        mTouchSlop = configuration.getScaledTouchSlop();
180
181        mSGD = new ScaleGestureDetector(context, mScaleGestureListener);
182        mFlingAnimationUtils = new FlingAnimationUtils(context, EXPAND_DURATION);
183    }
184
185    @VisibleForTesting
186    void updateExpansion() {
187        if (DEBUG_SCALE) Log.v(TAG, "updateExpansion()");
188        // are we scaling or dragging?
189        float span = mSGD.getCurrentSpan() - mInitialTouchSpan;
190        span *= USE_SPAN ? 1f : 0f;
191        float drag = mSGD.getFocusY() - mInitialTouchFocusY;
192        drag *= USE_DRAG ? 1f : 0f;
193        drag *= mGravity == Gravity.BOTTOM ? -1f : 1f;
194        float pull = Math.abs(drag) + Math.abs(span) + 1f;
195        float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull;
196        float target = hand + mOldHeight;
197        float newHeight = clamp(target);
198        mScaler.setHeight(newHeight);
199        mLastFocusY = mSGD.getFocusY();
200        mLastSpanY = mSGD.getCurrentSpan();
201    }
202
203    private float clamp(float target) {
204        float out = target;
205        out = out < mSmallSize ? mSmallSize : out;
206        out = out > mNaturalHeight ? mNaturalHeight : out;
207        return out;
208    }
209
210    private ExpandableView findView(float x, float y) {
211        ExpandableView v;
212        if (mEventSource != null) {
213            int[] location = new int[2];
214            mEventSource.getLocationOnScreen(location);
215            x += location[0];
216            y += location[1];
217            v = mCallback.getChildAtRawPosition(x, y);
218        } else {
219            v = mCallback.getChildAtPosition(x, y);
220        }
221        return v;
222    }
223
224    private boolean isInside(View v, float x, float y) {
225        if (DEBUG) Log.d(TAG, "isinside (" + x + ", " + y + ")");
226
227        if (v == null) {
228            if (DEBUG) Log.d(TAG, "isinside null subject");
229            return false;
230        }
231        if (mEventSource != null) {
232            int[] location = new int[2];
233            mEventSource.getLocationOnScreen(location);
234            x += location[0];
235            y += location[1];
236            if (DEBUG) Log.d(TAG, "  to global (" + x + ", " + y + ")");
237        }
238        int[] location = new int[2];
239        v.getLocationOnScreen(location);
240        x -= location[0];
241        y -= location[1];
242        if (DEBUG) Log.d(TAG, "  to local (" + x + ", " + y + ")");
243        if (DEBUG) Log.d(TAG, "  inside (" + v.getWidth() + ", " + v.getHeight() + ")");
244        boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight());
245        return inside;
246    }
247
248    public void setEventSource(View eventSource) {
249        mEventSource = eventSource;
250    }
251
252    public void setGravity(int gravity) {
253        mGravity = gravity;
254    }
255
256    public void setScrollAdapter(ScrollAdapter adapter) {
257        mScrollAdapter = adapter;
258    }
259
260    @Override
261    public boolean onInterceptTouchEvent(MotionEvent ev) {
262        if (!isEnabled()) {
263            return false;
264        }
265        trackVelocity(ev);
266        final int action = ev.getAction();
267        if (DEBUG_SCALE) Log.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) +
268                         " expanding=" + mExpanding +
269                         (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
270                         (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
271                         (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
272        // check for a spread-finger vertical pull gesture
273        mSGD.onTouchEvent(ev);
274        final int x = (int) mSGD.getFocusX();
275        final int y = (int) mSGD.getFocusY();
276
277        mInitialTouchFocusY = y;
278        mInitialTouchSpan = mSGD.getCurrentSpan();
279        mLastFocusY = mInitialTouchFocusY;
280        mLastSpanY = mInitialTouchSpan;
281        if (DEBUG_SCALE) Log.d(TAG, "set initial span: " + mInitialTouchSpan);
282
283        if (mExpanding) {
284            mLastMotionY = ev.getRawY();
285            maybeRecycleVelocityTracker(ev);
286            return true;
287        } else {
288            if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) {
289                // we've begun Venetian blinds style expansion
290                return true;
291            }
292            switch (action & MotionEvent.ACTION_MASK) {
293            case MotionEvent.ACTION_MOVE: {
294                final float xspan = mSGD.getCurrentSpanX();
295                if (xspan > mPullGestureMinXSpan &&
296                        xspan > mSGD.getCurrentSpanY() && !mExpanding) {
297                    // detect a vertical pulling gesture with fingers somewhat separated
298                    if (DEBUG_SCALE) Log.v(TAG, "got pull gesture (xspan=" + xspan + "px)");
299                    startExpanding(mResizedView, PULL);
300                    mWatchingForPull = false;
301                }
302                if (mWatchingForPull) {
303                    final float yDiff = ev.getRawY() - mInitialTouchY;
304                    final float xDiff = ev.getRawX() - mInitialTouchX;
305                    if (yDiff > mTouchSlop && yDiff > Math.abs(xDiff)) {
306                        if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)");
307                        mWatchingForPull = false;
308                        if (mResizedView != null && !isFullyExpanded(mResizedView)) {
309                            if (startExpanding(mResizedView, BLINDS)) {
310                                mLastMotionY = ev.getRawY();
311                                mInitialTouchY = ev.getRawY();
312                                mHasPopped = false;
313                            }
314                        }
315                    }
316                }
317                break;
318            }
319
320            case MotionEvent.ACTION_DOWN:
321                mWatchingForPull = mScrollAdapter != null &&
322                        isInside(mScrollAdapter.getHostView(), x, y)
323                        && mScrollAdapter.isScrolledToTop();
324                mResizedView = findView(x, y);
325                if (mResizedView != null && !mCallback.canChildBeExpanded(mResizedView)) {
326                    mResizedView = null;
327                    mWatchingForPull = false;
328                }
329                mInitialTouchY = ev.getRawY();
330                mInitialTouchX = ev.getRawX();
331                break;
332
333            case MotionEvent.ACTION_CANCEL:
334            case MotionEvent.ACTION_UP:
335                if (DEBUG) Log.d(TAG, "up/cancel");
336                finishExpanding(ev.getActionMasked() == MotionEvent.ACTION_CANCEL /* forceAbort */,
337                        getCurrentVelocity());
338                clearView();
339                break;
340            }
341            mLastMotionY = ev.getRawY();
342            maybeRecycleVelocityTracker(ev);
343            return mExpanding;
344        }
345    }
346
347    private void trackVelocity(MotionEvent event) {
348        int action = event.getActionMasked();
349        switch(action) {
350            case MotionEvent.ACTION_DOWN:
351                if (mVelocityTracker == null) {
352                    mVelocityTracker = VelocityTracker.obtain();
353                } else {
354                    mVelocityTracker.clear();
355                }
356                mVelocityTracker.addMovement(event);
357                break;
358            case MotionEvent.ACTION_MOVE:
359                if (mVelocityTracker == null) {
360                    mVelocityTracker = VelocityTracker.obtain();
361                }
362                mVelocityTracker.addMovement(event);
363                break;
364            default:
365                break;
366        }
367    }
368
369    private void maybeRecycleVelocityTracker(MotionEvent event) {
370        if (mVelocityTracker != null && (event.getActionMasked() == MotionEvent.ACTION_CANCEL
371                || event.getActionMasked() == MotionEvent.ACTION_UP)) {
372            mVelocityTracker.recycle();
373            mVelocityTracker = null;
374        }
375    }
376
377    private float getCurrentVelocity() {
378        if (mVelocityTracker != null) {
379            mVelocityTracker.computeCurrentVelocity(1000);
380            return mVelocityTracker.getYVelocity();
381        } else {
382            return 0f;
383        }
384    }
385
386    public void setEnabled(boolean enable) {
387        mEnabled = enable;
388    }
389
390    private boolean isEnabled() {
391        return mEnabled;
392    }
393
394    private boolean isFullyExpanded(ExpandableView underFocus) {
395        return underFocus.getIntrinsicHeight() == underFocus.getMaxContentHeight()
396                && (!underFocus.isSummaryWithChildren() || underFocus.areChildrenExpanded());
397    }
398
399    @Override
400    public boolean onTouchEvent(MotionEvent ev) {
401        if (!isEnabled() && !mExpanding) {
402            // In case we're expanding we still want to finish the current motion.
403            return false;
404        }
405        trackVelocity(ev);
406        final int action = ev.getActionMasked();
407        if (DEBUG_SCALE) Log.d(TAG, "touch: act=" + MotionEvent.actionToString(action) +
408                " expanding=" + mExpanding +
409                (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
410                (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
411                (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
412
413        mSGD.onTouchEvent(ev);
414        final int x = (int) mSGD.getFocusX();
415        final int y = (int) mSGD.getFocusY();
416
417        if (mOnlyMovements) {
418            mLastMotionY = ev.getRawY();
419            return false;
420        }
421        switch (action) {
422            case MotionEvent.ACTION_DOWN:
423                mWatchingForPull = mScrollAdapter != null &&
424                        isInside(mScrollAdapter.getHostView(), x, y);
425                mResizedView = findView(x, y);
426                mInitialTouchX = ev.getRawX();
427                mInitialTouchY = ev.getRawY();
428                break;
429            case MotionEvent.ACTION_MOVE: {
430                if (mWatchingForPull) {
431                    final float yDiff = ev.getRawY() - mInitialTouchY;
432                    final float xDiff = ev.getRawX() - mInitialTouchX;
433                    if (yDiff > mTouchSlop && yDiff > Math.abs(xDiff)) {
434                        if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)");
435                        mWatchingForPull = false;
436                        if (mResizedView != null && !isFullyExpanded(mResizedView)) {
437                            if (startExpanding(mResizedView, BLINDS)) {
438                                mInitialTouchY = ev.getRawY();
439                                mLastMotionY = ev.getRawY();
440                                mHasPopped = false;
441                            }
442                        }
443                    }
444                }
445                if (mExpanding && 0 != (mExpansionStyle & BLINDS)) {
446                    final float rawHeight = ev.getRawY() - mLastMotionY + mCurrentHeight;
447                    final float newHeight = clamp(rawHeight);
448                    boolean isFinished = false;
449                    boolean expanded = false;
450                    if (rawHeight > mNaturalHeight) {
451                        isFinished = true;
452                        expanded = true;
453                    }
454                    if (rawHeight < mSmallSize) {
455                        isFinished = true;
456                        expanded = false;
457                    }
458
459                    if (!mHasPopped) {
460                        if (mEventSource != null) {
461                            mEventSource.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
462                        }
463                        mHasPopped = true;
464                    }
465
466                    mScaler.setHeight(newHeight);
467                    mLastMotionY = ev.getRawY();
468                    if (isFinished) {
469                        mCallback.expansionStateChanged(false);
470                    } else {
471                        mCallback.expansionStateChanged(true);
472                    }
473                    return true;
474                }
475
476                if (mExpanding) {
477
478                    // Gestural expansion is running
479                    updateExpansion();
480                    mLastMotionY = ev.getRawY();
481                    return true;
482                }
483
484                break;
485            }
486
487            case MotionEvent.ACTION_POINTER_UP:
488            case MotionEvent.ACTION_POINTER_DOWN:
489                if (DEBUG) Log.d(TAG, "pointer change");
490                mInitialTouchY += mSGD.getFocusY() - mLastFocusY;
491                mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY;
492                break;
493
494            case MotionEvent.ACTION_UP:
495            case MotionEvent.ACTION_CANCEL:
496                if (DEBUG) Log.d(TAG, "up/cancel");
497                finishExpanding(!isEnabled() || ev.getActionMasked() == MotionEvent.ACTION_CANCEL,
498                        getCurrentVelocity());
499                clearView();
500                break;
501        }
502        mLastMotionY = ev.getRawY();
503        maybeRecycleVelocityTracker(ev);
504        return mResizedView != null;
505    }
506
507    /**
508     * @return True if the view is expandable, false otherwise.
509     */
510    @VisibleForTesting
511    boolean startExpanding(ExpandableView v, int expandType) {
512        if (!(v instanceof ExpandableNotificationRow)) {
513            return false;
514        }
515        mExpansionStyle = expandType;
516        if (mExpanding && v == mResizedView) {
517            return true;
518        }
519        mExpanding = true;
520        mCallback.expansionStateChanged(true);
521        if (DEBUG) Log.d(TAG, "scale type " + expandType + " beginning on view: " + v);
522        mCallback.setUserLockedChild(v, true);
523        mScaler.setView(v);
524        mOldHeight = mScaler.getHeight();
525        mCurrentHeight = mOldHeight;
526        boolean canBeExpanded = mCallback.canChildBeExpanded(v);
527        if (canBeExpanded) {
528            if (DEBUG) Log.d(TAG, "working on an expandable child");
529            mNaturalHeight = mScaler.getNaturalHeight();
530            mSmallSize = v.getCollapsedHeight();
531        } else {
532            if (DEBUG) Log.d(TAG, "working on a non-expandable child");
533            mNaturalHeight = mOldHeight;
534        }
535        if (DEBUG) Log.d(TAG, "got mOldHeight: " + mOldHeight +
536                    " mNaturalHeight: " + mNaturalHeight);
537        return true;
538    }
539
540    /**
541     * Finish the current expand motion
542     * @param forceAbort whether the expansion should be forcefully aborted and returned to the old
543     *                   state
544     * @param velocity the velocity this was expanded/ collapsed with
545     */
546    @VisibleForTesting
547    void finishExpanding(boolean forceAbort, float velocity) {
548        if (!mExpanding) return;
549
550        if (DEBUG) Log.d(TAG, "scale in finishing on view: " + mResizedView);
551
552        float currentHeight = mScaler.getHeight();
553        final boolean wasClosed = (mOldHeight == mSmallSize);
554        boolean nowExpanded;
555        if (!forceAbort) {
556            if (wasClosed) {
557                nowExpanded = currentHeight > mOldHeight && velocity >= 0;
558            } else {
559                nowExpanded = currentHeight >= mOldHeight || velocity > 0;
560            }
561            nowExpanded |= mNaturalHeight == mSmallSize;
562        } else {
563            nowExpanded = !wasClosed;
564        }
565        if (mScaleAnimation.isRunning()) {
566            mScaleAnimation.cancel();
567        }
568        mCallback.expansionStateChanged(false);
569        int naturalHeight = mScaler.getNaturalHeight();
570        float targetHeight = nowExpanded ? naturalHeight : mSmallSize;
571        if (targetHeight != currentHeight && mEnabled) {
572            mScaleAnimation.setFloatValues(targetHeight);
573            mScaleAnimation.setupStartValues();
574            final View scaledView = mResizedView;
575            final boolean expand = nowExpanded;
576            mScaleAnimation.addListener(new AnimatorListenerAdapter() {
577                public boolean mCancelled;
578
579                @Override
580                public void onAnimationEnd(Animator animation) {
581                    if (!mCancelled) {
582                        mCallback.setUserExpandedChild(scaledView, expand);
583                        if (!mExpanding) {
584                            mScaler.setView(null);
585                        }
586                    } else {
587                        mCallback.setExpansionCancelled(scaledView);
588                    }
589                    mCallback.setUserLockedChild(scaledView, false);
590                    mScaleAnimation.removeListener(this);
591                }
592
593                @Override
594                public void onAnimationCancel(Animator animation) {
595                    mCancelled = true;
596                }
597            });
598            velocity = nowExpanded == velocity >= 0 ? velocity : 0;
599            mFlingAnimationUtils.apply(mScaleAnimation, currentHeight, targetHeight, velocity);
600            mScaleAnimation.start();
601        } else {
602            if (targetHeight != currentHeight) {
603                mScaler.setHeight(targetHeight);
604            }
605            mCallback.setUserExpandedChild(mResizedView, nowExpanded);
606            mCallback.setUserLockedChild(mResizedView, false);
607            mScaler.setView(null);
608        }
609
610        mExpanding = false;
611        mExpansionStyle = NONE;
612
613        if (DEBUG) Log.d(TAG, "wasClosed is: " + wasClosed);
614        if (DEBUG) Log.d(TAG, "currentHeight is: " + currentHeight);
615        if (DEBUG) Log.d(TAG, "mSmallSize is: " + mSmallSize);
616        if (DEBUG) Log.d(TAG, "targetHeight is: " + targetHeight);
617        if (DEBUG) Log.d(TAG, "scale was finished on view: " + mResizedView);
618    }
619
620    private void clearView() {
621        mResizedView = null;
622    }
623
624    /**
625     * Use this to abort any pending expansions in progress.
626     */
627    public void cancel() {
628        finishExpanding(true /* forceAbort */, 0f /* velocity */);
629        clearView();
630
631        // reset the gesture detector
632        mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener);
633    }
634
635    /**
636     * Change the expansion mode to only observe movements and don't perform any resizing.
637     * This is needed when the expanding is finished and the scroller kicks in,
638     * performing an overscroll motion. We only want to shrink it again when we are not
639     * overscrolled.
640     *
641     * @param onlyMovements Should only movements be observed?
642     */
643    public void onlyObserveMovements(boolean onlyMovements) {
644        mOnlyMovements = onlyMovements;
645    }
646}
647
648