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