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