ExpandHelper.java revision fab078b01fbad026f006744016272327f7ab116b
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.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.View;
33import android.view.View.OnClickListener;
34import android.view.ViewConfiguration;
35import android.view.ViewGroup;
36
37public class ExpandHelper implements Gefingerpoken, OnClickListener {
38    public interface Callback {
39        View getChildAtRawPosition(float x, float y);
40        View getChildAtPosition(float x, float y);
41        boolean canChildBeExpanded(View v);
42        void setUserExpandedChild(View v, boolean userExpanded);
43        void setUserLockedChild(View v, boolean userLocked);
44    }
45
46    private static final String TAG = "ExpandHelper";
47    protected static final boolean DEBUG = false;
48    protected static final boolean DEBUG_SCALE = false;
49    protected static final boolean DEBUG_GLOW = false;
50    private static final long EXPAND_DURATION = 250;
51    private static final long GLOW_DURATION = 150;
52
53    // Set to false to disable focus-based gestures (spread-finger vertical pull).
54    private static final boolean USE_DRAG = true;
55    // Set to false to disable scale-based gestures (both horizontal and vertical).
56    private static final boolean USE_SPAN = true;
57    // Both gestures types may be active at the same time.
58    // At least one gesture type should be active.
59    // A variant of the screwdriver gesture will emerge from either gesture type.
60
61    // amount of overstretch for maximum brightness expressed in U
62    // 2f: maximum brightness is stretching a 1U to 3U, or a 4U to 6U
63    private static final float STRETCH_INTERVAL = 2f;
64
65    // level of glow for a touch, without overstretch
66    // overstretch fills the range (GLOW_BASE, 1.0]
67    private static final float GLOW_BASE = 0.5f;
68
69    @SuppressWarnings("unused")
70    private Context mContext;
71
72    private boolean mExpanding;
73    private static final int NONE    = 0;
74    private static final int BLINDS  = 1<<0;
75    private static final int PULL    = 1<<1;
76    private static final int STRETCH = 1<<2;
77    private int mExpansionStyle = NONE;
78    private boolean mWatchingForPull;
79    private boolean mHasPopped;
80    private View mEventSource;
81    private View mCurrView;
82    private View mCurrViewTopGlow;
83    private View mCurrViewBottomGlow;
84    private float mOldHeight;
85    private float mNaturalHeight;
86    private float mInitialTouchFocusY;
87    private float mInitialTouchY;
88    private float mInitialTouchSpan;
89    private float mLastFocusY;
90    private float mLastSpanY;
91    private int mTouchSlop;
92    private int mLastMotionY;
93    private float mPopLimit;
94    private int mPopDuration;
95    private float mPullGestureMinXSpan;
96    private Callback mCallback;
97    private ScaleGestureDetector mSGD;
98    private ViewScaler mScaler;
99    private ObjectAnimator mScaleAnimation;
100    private AnimatorSet mGlowAnimationSet;
101    private ObjectAnimator mGlowTopAnimation;
102    private ObjectAnimator mGlowBottomAnimation;
103    private Vibrator mVibrator;
104
105    private int mSmallSize;
106    private int mLargeSize;
107    private float mMaximumStretch;
108
109    private int mGravity;
110
111    private ScrollAdapter mScrollAdapter;
112
113    private OnScaleGestureListener mScaleGestureListener
114            = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
115        @Override
116        public boolean onScaleBegin(ScaleGestureDetector detector) {
117            if (DEBUG_SCALE) Log.v(TAG, "onscalebegin()");
118            float focusX = detector.getFocusX();
119            float focusY = detector.getFocusY();
120
121            final View underFocus = findView(focusX, focusY);
122            if (underFocus != null) {
123                startExpanding(underFocus, STRETCH);
124            }
125            return mExpanding;
126        }
127
128        @Override
129        public boolean onScale(ScaleGestureDetector detector) {
130            if (DEBUG_SCALE) Log.v(TAG, "onscale() on " + mCurrView);
131            return true;
132        }
133
134        @Override
135        public void onScaleEnd(ScaleGestureDetector detector) {
136        }
137    };
138
139    private class ViewScaler {
140        View mView;
141
142        public ViewScaler() {}
143        public void setView(View v) {
144            mView = v;
145        }
146        public void setHeight(float h) {
147            if (DEBUG_SCALE) Log.v(TAG, "SetHeight: setting to " + h);
148            ViewGroup.LayoutParams lp = mView.getLayoutParams();
149            lp.height = (int)h;
150            mView.setLayoutParams(lp);
151            mView.requestLayout();
152        }
153        public float getHeight() {
154            int height = mView.getLayoutParams().height;
155            if (height < 0) {
156                height = mView.getMeasuredHeight();
157            }
158            return height;
159        }
160        public int getNaturalHeight(int maximum) {
161            ViewGroup.LayoutParams lp = mView.getLayoutParams();
162            if (DEBUG_SCALE) Log.v(TAG, "Inspecting a child of type: " +
163                    mView.getClass().getName());
164            int oldHeight = lp.height;
165            lp.height = ViewGroup.LayoutParams.WRAP_CONTENT;
166            mView.setLayoutParams(lp);
167            mView.measure(
168                    View.MeasureSpec.makeMeasureSpec(mView.getMeasuredWidth(),
169                                                     View.MeasureSpec.EXACTLY),
170                    View.MeasureSpec.makeMeasureSpec(maximum,
171                                                     View.MeasureSpec.AT_MOST));
172            lp.height = oldHeight;
173            mView.setLayoutParams(lp);
174            return mView.getMeasuredHeight();
175        }
176    }
177
178    /**
179     * Handle expansion gestures to expand and contract children of the callback.
180     *
181     * @param context application context
182     * @param callback the container that holds the items to be manipulated
183     * @param small the smallest allowable size for the manuipulated items.
184     * @param large the largest allowable size for the manuipulated items.
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 setScrollAdapter(ScrollAdapter adapter) {
305        mScrollAdapter = adapter;
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 (mScrollAdapter != null && !mScrollAdapter.isScrolledToTop()) {
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(mScrollAdapter.getHostView(), 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, AudioManager.STREAM_SYSTEM);
610    }
611
612    public interface ScrollAdapter {
613
614        /**
615         * @return Whether the view returned by {@link #getHostView()} is scrolled to the top
616         * and can therefore be expanded by a single finger drag
617         */
618        public boolean isScrolledToTop();
619
620        /**
621         * @return The view in which the scrolling is performed
622         */
623        public View getHostView();
624    }
625}
626
627