ExpandHelper.java revision de84f0e77ea2bf713d15c290264059a413c2486a
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        boolean setUserExpandedChild(View v, boolean userExpanded);
42        boolean 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     * @param scoller if non-null also manipulate the scroll position to obey the gravity.
185     */
186    public ExpandHelper(Context context, Callback callback, int small, int large) {
187        mSmallSize = small;
188        mMaximumStretch = mSmallSize * STRETCH_INTERVAL;
189        mLargeSize = large;
190        mContext = context;
191        mCallback = callback;
192        mScaler = new ViewScaler();
193        mGravity = Gravity.TOP;
194        mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f);
195        mScaleAnimation.setDuration(EXPAND_DURATION);
196        mPopLimit = mContext.getResources().getDimension(R.dimen.blinds_pop_threshold);
197        mPopDuration = mContext.getResources().getInteger(R.integer.blinds_pop_duration_ms);
198        mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min);
199
200        AnimatorListenerAdapter glowVisibilityController = new AnimatorListenerAdapter() {
201            @Override
202            public void onAnimationStart(Animator animation) {
203                View target = (View) ((ObjectAnimator) animation).getTarget();
204                if (target.getAlpha() <= 0.0f) {
205                    target.setVisibility(View.VISIBLE);
206                }
207            }
208
209            @Override
210            public void onAnimationEnd(Animator animation) {
211                View target = (View) ((ObjectAnimator) animation).getTarget();
212                if (target.getAlpha() <= 0.0f) {
213                    target.setVisibility(View.INVISIBLE);
214                }
215            }
216        };
217
218        mGlowTopAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f);
219        mGlowTopAnimation.addListener(glowVisibilityController);
220        mGlowBottomAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f);
221        mGlowBottomAnimation.addListener(glowVisibilityController);
222        mGlowAnimationSet = new AnimatorSet();
223        mGlowAnimationSet.play(mGlowTopAnimation).with(mGlowBottomAnimation);
224        mGlowAnimationSet.setDuration(GLOW_DURATION);
225
226        final ViewConfiguration configuration = ViewConfiguration.get(mContext);
227        mTouchSlop = configuration.getScaledTouchSlop();
228
229        mSGD = new ScaleGestureDetector(context, mScaleGestureListener);
230    }
231
232    private void updateExpansion() {
233        if (DEBUG_SCALE) Log.v(TAG, "updateExpansion()");
234        // are we scaling or dragging?
235        float span = mSGD.getCurrentSpan() - mInitialTouchSpan;
236        span *= USE_SPAN ? 1f : 0f;
237        float drag = mSGD.getFocusY() - mInitialTouchFocusY;
238        drag *= USE_DRAG ? 1f : 0f;
239        drag *= mGravity == Gravity.BOTTOM ? -1f : 1f;
240        float pull = Math.abs(drag) + Math.abs(span) + 1f;
241        float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull;
242        float target = hand + mOldHeight;
243        float newHeight = clamp(target);
244        mScaler.setHeight(newHeight);
245
246        setGlow(calculateGlow(target, newHeight));
247        mLastFocusY = mSGD.getFocusY();
248        mLastSpanY = mSGD.getCurrentSpan();
249    }
250
251    private float clamp(float target) {
252        float out = target;
253        out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out);
254        out = out > mNaturalHeight ? mNaturalHeight : out;
255        return out;
256    }
257
258    private View findView(float x, float y) {
259        View v = null;
260        if (mEventSource != null) {
261            int[] location = new int[2];
262            mEventSource.getLocationOnScreen(location);
263            x += location[0];
264            y += location[1];
265            v = mCallback.getChildAtRawPosition(x, y);
266        } else {
267            v = mCallback.getChildAtPosition(x, y);
268        }
269        return v;
270    }
271
272    private boolean isInside(View v, float x, float y) {
273        if (DEBUG) Log.d(TAG, "isinside (" + x + ", " + y + ")");
274
275        if (v == null) {
276            if (DEBUG) Log.d(TAG, "isinside null subject");
277            return false;
278        }
279        if (mEventSource != null) {
280            int[] location = new int[2];
281            mEventSource.getLocationOnScreen(location);
282            x += location[0];
283            y += location[1];
284            if (DEBUG) Log.d(TAG, "  to global (" + x + ", " + y + ")");
285        }
286        int[] location = new int[2];
287        v.getLocationOnScreen(location);
288        x -= location[0];
289        y -= location[1];
290        if (DEBUG) Log.d(TAG, "  to local (" + x + ", " + y + ")");
291        if (DEBUG) Log.d(TAG, "  inside (" + v.getWidth() + ", " + v.getHeight() + ")");
292        boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight());
293        return inside;
294    }
295
296    public void setEventSource(View eventSource) {
297        mEventSource = eventSource;
298    }
299
300    public void setGravity(int gravity) {
301        mGravity = gravity;
302    }
303
304    public void setScrollView(View scrollView) {
305        mScrollView = scrollView;
306    }
307
308    private float calculateGlow(float target, float actual) {
309        // glow if overscale
310        if (DEBUG_GLOW) Log.d(TAG, "target: " + target + " actual: " + actual);
311        float stretch = Math.abs((target - actual) / mMaximumStretch);
312        float strength = 1f / (1f + (float) Math.pow(Math.E, -1 * ((8f * stretch) - 5f)));
313        if (DEBUG_GLOW) Log.d(TAG, "stretch: " + stretch + " strength: " + strength);
314        return (GLOW_BASE + strength * (1f - GLOW_BASE));
315    }
316
317    public void setGlow(float glow) {
318        if (!mGlowAnimationSet.isRunning() || glow == 0f) {
319            if (mGlowAnimationSet.isRunning()) {
320                mGlowAnimationSet.end();
321            }
322            if (mCurrViewTopGlow != null && mCurrViewBottomGlow != null) {
323                if (glow == 0f || mCurrViewTopGlow.getAlpha() == 0f) {
324                    // animate glow in and out
325                    mGlowTopAnimation.setTarget(mCurrViewTopGlow);
326                    mGlowBottomAnimation.setTarget(mCurrViewBottomGlow);
327                    mGlowTopAnimation.setFloatValues(glow);
328                    mGlowBottomAnimation.setFloatValues(glow);
329                    mGlowAnimationSet.setupStartValues();
330                    mGlowAnimationSet.start();
331                } else {
332                    // set it explicitly in reponse to touches.
333                    mCurrViewTopGlow.setAlpha(glow);
334                    mCurrViewBottomGlow.setAlpha(glow);
335                    handleGlowVisibility();
336                }
337            }
338        }
339    }
340
341    private void handleGlowVisibility() {
342        mCurrViewTopGlow.setVisibility(mCurrViewTopGlow.getAlpha() <= 0.0f ?
343                View.INVISIBLE : View.VISIBLE);
344        mCurrViewBottomGlow.setVisibility(mCurrViewBottomGlow.getAlpha() <= 0.0f ?
345                View.INVISIBLE : View.VISIBLE);
346    }
347
348    @Override
349    public boolean onInterceptTouchEvent(MotionEvent ev) {
350        final int action = ev.getAction();
351        if (DEBUG_SCALE) Log.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) +
352                         " expanding=" + mExpanding +
353                         (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
354                         (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
355                         (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
356        // check for a spread-finger vertical pull gesture
357        mSGD.onTouchEvent(ev);
358        final int x = (int) mSGD.getFocusX();
359        final int y = (int) mSGD.getFocusY();
360
361        mInitialTouchFocusY = y;
362        mInitialTouchSpan = mSGD.getCurrentSpan();
363        mLastFocusY = mInitialTouchFocusY;
364        mLastSpanY = mInitialTouchSpan;
365        if (DEBUG_SCALE) Log.d(TAG, "set initial span: " + mInitialTouchSpan);
366
367        if (mExpanding) {
368            return true;
369        } else {
370            if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) {
371                // we've begun Venetian blinds style expansion
372                return true;
373            }
374            final float xspan = mSGD.getCurrentSpanX();
375            if ((action == MotionEvent.ACTION_MOVE &&
376                    xspan > mPullGestureMinXSpan &&
377                    xspan > mSGD.getCurrentSpanY())) {
378                // detect a vertical pulling gesture with fingers somewhat separated
379                if (DEBUG_SCALE) Log.v(TAG, "got pull gesture (xspan=" + xspan + "px)");
380
381                final View underFocus = findView(x, y);
382                if (underFocus != null) {
383                    startExpanding(underFocus, PULL);
384                }
385                return true;
386            }
387            if (mScrollView != null && mScrollView.getScrollY() > 0) {
388                return false;
389            }
390            // Now look for other gestures
391            switch (action & MotionEvent.ACTION_MASK) {
392            case MotionEvent.ACTION_MOVE: {
393                if (mWatchingForPull) {
394                    final int yDiff = y - mLastMotionY;
395                    if (yDiff > mTouchSlop) {
396                        if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)");
397                        mLastMotionY = y;
398                        final View underFocus = findView(x, y);
399                        if (underFocus != null) {
400                            startExpanding(underFocus, BLINDS);
401                            mInitialTouchY = mLastMotionY;
402                            mHasPopped = false;
403                        }
404                    }
405                }
406                break;
407            }
408
409            case MotionEvent.ACTION_DOWN:
410                mWatchingForPull = isInside(mScrollView, x, y);
411                mLastMotionY = y;
412                break;
413
414            case MotionEvent.ACTION_CANCEL:
415            case MotionEvent.ACTION_UP:
416                if (DEBUG) Log.d(TAG, "up/cancel");
417                finishExpanding(false);
418                clearView();
419                break;
420            }
421            return mExpanding;
422        }
423    }
424
425    @Override
426    public boolean onTouchEvent(MotionEvent ev) {
427        final int action = ev.getActionMasked();
428        if (DEBUG_SCALE) Log.d(TAG, "touch: act=" + MotionEvent.actionToString(action) +
429                " expanding=" + mExpanding +
430                (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
431                (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
432                (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
433
434        mSGD.onTouchEvent(ev);
435
436        switch (action) {
437            case MotionEvent.ACTION_MOVE: {
438                if (0 != (mExpansionStyle & BLINDS)) {
439                    final float rawHeight = ev.getY() - mInitialTouchY + mOldHeight;
440                    final float newHeight = clamp(rawHeight);
441                    final boolean wasClosed = (mOldHeight == mSmallSize);
442                    boolean isFinished = false;
443                    if (rawHeight > mNaturalHeight) {
444                        isFinished = true;
445                    }
446                    if (rawHeight < mSmallSize) {
447                        isFinished = true;
448                    }
449
450                    final float pull = Math.abs(ev.getY() - mInitialTouchY);
451                    if (mHasPopped || pull > mPopLimit) {
452                        if (!mHasPopped) {
453                            vibrate(mPopDuration);
454                            mHasPopped = true;
455                        }
456                    }
457
458                    if (mHasPopped) {
459                        mScaler.setHeight(newHeight);
460                        setGlow(GLOW_BASE);
461                    } else {
462                        setGlow(calculateGlow(4f * pull, 0f));
463                    }
464
465                    final int x = (int) mSGD.getFocusX();
466                    final int y = (int) mSGD.getFocusY();
467                    View underFocus = findView(x, y);
468                    if (isFinished && underFocus != null && underFocus != mCurrView) {
469                        finishExpanding(false); // @@@ needed?
470                        startExpanding(underFocus, BLINDS);
471                        mInitialTouchY = y;
472                        mHasPopped = false;
473                    }
474                    return true;
475                }
476
477                if (mExpanding) {
478                    updateExpansion();
479                    return true;
480                }
481
482                break;
483            }
484
485            case MotionEvent.ACTION_POINTER_UP:
486            case MotionEvent.ACTION_POINTER_DOWN:
487                if (DEBUG) Log.d(TAG, "pointer change");
488                mInitialTouchY += mSGD.getFocusY() - mLastFocusY;
489                mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY;
490                break;
491
492            case MotionEvent.ACTION_UP:
493            case MotionEvent.ACTION_CANCEL:
494                if (DEBUG) Log.d(TAG, "up/cancel");
495                finishExpanding(false);
496                clearView();
497                break;
498        }
499        return true;
500    }
501
502    private void startExpanding(View v, int expandType) {
503        mExpansionStyle = expandType;
504        if (mExpanding &&  v == mCurrView) {
505            return;
506        }
507        mExpanding = true;
508        if (DEBUG) Log.d(TAG, "scale type " + expandType + " beginning on view: " + v);
509        mCallback.setUserLockedChild(v, true);
510        setView(v);
511        setGlow(GLOW_BASE);
512        mScaler.setView(v);
513        mOldHeight = mScaler.getHeight();
514        if (mCallback.canChildBeExpanded(v)) {
515            if (DEBUG) Log.d(TAG, "working on an expandable child");
516            mNaturalHeight = mScaler.getNaturalHeight(mLargeSize);
517        } else {
518            if (DEBUG) Log.d(TAG, "working on a non-expandable child");
519            mNaturalHeight = mOldHeight;
520        }
521        if (DEBUG) Log.d(TAG, "got mOldHeight: " + mOldHeight +
522                    " mNaturalHeight: " + mNaturalHeight);
523        v.getParent().requestDisallowInterceptTouchEvent(true);
524    }
525
526    private void finishExpanding(boolean force) {
527        if (!mExpanding) return;
528
529        if (DEBUG) Log.d(TAG, "scale in finishing on view: " + mCurrView);
530
531        float currentHeight = mScaler.getHeight();
532        float targetHeight = mSmallSize;
533        float h = mScaler.getHeight();
534        final boolean wasClosed = (mOldHeight == mSmallSize);
535        if (wasClosed) {
536            targetHeight = (force || currentHeight > mSmallSize) ? mNaturalHeight : mSmallSize;
537        } else {
538            targetHeight = (force || currentHeight < mNaturalHeight) ? mSmallSize : mNaturalHeight;
539        }
540        if (mScaleAnimation.isRunning()) {
541            mScaleAnimation.cancel();
542        }
543        setGlow(0f);
544        mCallback.setUserExpandedChild(mCurrView, h == mNaturalHeight);
545        if (targetHeight != currentHeight) {
546            mScaleAnimation.setFloatValues(targetHeight);
547            mScaleAnimation.setupStartValues();
548            mScaleAnimation.start();
549        }
550        mCallback.setUserLockedChild(mCurrView, false);
551
552        mExpanding = false;
553        mExpansionStyle = NONE;
554
555        if (DEBUG) Log.d(TAG, "wasClosed is: " + wasClosed);
556        if (DEBUG) Log.d(TAG, "currentHeight is: " + currentHeight);
557        if (DEBUG) Log.d(TAG, "mSmallSize is: " + mSmallSize);
558        if (DEBUG) Log.d(TAG, "targetHeight is: " + targetHeight);
559        if (DEBUG) Log.d(TAG, "scale was finished on view: " + mCurrView);
560    }
561
562    private void clearView() {
563        mCurrView = null;
564        mCurrViewTopGlow = null;
565        mCurrViewBottomGlow = null;
566    }
567
568    private void setView(View v) {
569        mCurrView = v;
570        if (v instanceof ViewGroup) {
571            ViewGroup g = (ViewGroup) v;
572            mCurrViewTopGlow = g.findViewById(R.id.top_glow);
573            mCurrViewBottomGlow = g.findViewById(R.id.bottom_glow);
574            if (DEBUG) {
575                String debugLog = "Looking for glows: " +
576                        (mCurrViewTopGlow != null ? "found top " : "didn't find top") +
577                        (mCurrViewBottomGlow != null ? "found bottom " : "didn't find bottom");
578                Log.v(TAG,  debugLog);
579            }
580        }
581    }
582
583    @Override
584    public void onClick(View v) {
585        startExpanding(v, STRETCH);
586        finishExpanding(true);
587        clearView();
588    }
589
590    /**
591     * Use this to abort any pending expansions in progress.
592     */
593    public void cancel() {
594        finishExpanding(true);
595        clearView();
596
597        // reset the gesture detector
598        mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener);
599    }
600
601    /**
602     * Triggers haptic feedback.
603     */
604    private synchronized void vibrate(long duration) {
605        if (mVibrator == null) {
606            mVibrator = (android.os.Vibrator)
607                    mContext.getSystemService(Context.VIBRATOR_SERVICE);
608        }
609        mVibrator.vibrate(duration);
610    }
611}
612
613