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