ExpandHelper.java revision 4222d9a7fb87d73e1443ec1a2de9782b05741af6
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.AudioManager;
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.ViewConfiguration;
33
34import com.android.systemui.statusbar.ExpandableNotificationRow;
35import com.android.systemui.statusbar.ExpandableView;
36import com.android.systemui.statusbar.policy.ScrollAdapter;
37
38public class ExpandHelper implements Gefingerpoken {
39    public interface Callback {
40        ExpandableView getChildAtRawPosition(float x, float y);
41        ExpandableView getChildAtPosition(float x, float y);
42        boolean canChildBeExpanded(View v);
43        void setUserExpandedChild(View v, boolean userExpanded);
44        void setUserLockedChild(View v, boolean userLocked);
45    }
46
47    private static final String TAG = "ExpandHelper";
48    protected static final boolean DEBUG = false;
49    protected static final boolean DEBUG_SCALE = false;
50    private static final long EXPAND_DURATION = 250;
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 float mOldHeight;
82    private float mNaturalHeight;
83    private float mInitialTouchFocusY;
84    private float mInitialTouchY;
85    private float mInitialTouchSpan;
86    private float mLastFocusY;
87    private float mLastSpanY;
88    private int mTouchSlop;
89    private int mLastMotionY;
90    private float mPopLimit;
91    private int mPopDuration;
92    private float mPullGestureMinXSpan;
93    private Callback mCallback;
94    private ScaleGestureDetector mSGD;
95    private ViewScaler mScaler;
96    private ObjectAnimator mScaleAnimation;
97    private Vibrator mVibrator;
98
99    private int mSmallSize;
100    private int mLargeSize;
101    private float mMaximumStretch;
102
103    private int mGravity;
104
105    private ScrollAdapter mScrollAdapter;
106
107    private OnScaleGestureListener mScaleGestureListener
108            = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
109        @Override
110        public boolean onScaleBegin(ScaleGestureDetector detector) {
111            if (DEBUG_SCALE) Log.v(TAG, "onscalebegin()");
112            float focusX = detector.getFocusX();
113            float focusY = detector.getFocusY();
114
115            final ExpandableView underFocus = findView(focusX, focusY);
116            startExpanding(underFocus, STRETCH);
117            return mExpanding;
118        }
119
120        @Override
121        public boolean onScale(ScaleGestureDetector detector) {
122            if (DEBUG_SCALE) Log.v(TAG, "onscale() on " + mCurrView);
123            return true;
124        }
125
126        @Override
127        public void onScaleEnd(ScaleGestureDetector detector) {
128        }
129    };
130
131    private class ViewScaler {
132        ExpandableView mView;
133
134        public ViewScaler() {}
135        public void setView(ExpandableView v) {
136            mView = v;
137        }
138        public void setHeight(float h) {
139            if (DEBUG_SCALE) Log.v(TAG, "SetHeight: setting to " + h);
140            mView.setActualHeight((int) h);
141        }
142        public float getHeight() {
143            return mView.getActualHeight();
144        }
145        public int getNaturalHeight(int maximum) {
146            return Math.min(maximum, mView.getMaxHeight());
147        }
148    }
149
150    /**
151     * Handle expansion gestures to expand and contract children of the callback.
152     *
153     * @param context application context
154     * @param callback the container that holds the items to be manipulated
155     * @param small the smallest allowable size for the manuipulated items.
156     * @param large the largest allowable size for the manuipulated items.
157     */
158    public ExpandHelper(Context context, Callback callback, int small, int large) {
159        mSmallSize = small;
160        mMaximumStretch = mSmallSize * STRETCH_INTERVAL;
161        mLargeSize = large;
162        mContext = context;
163        mCallback = callback;
164        mScaler = new ViewScaler();
165        mGravity = Gravity.TOP;
166        mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f);
167        mScaleAnimation.setDuration(EXPAND_DURATION);
168        mPopLimit = mContext.getResources().getDimension(R.dimen.blinds_pop_threshold);
169        mPopDuration = mContext.getResources().getInteger(R.integer.blinds_pop_duration_ms);
170        mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min);
171
172        final ViewConfiguration configuration = ViewConfiguration.get(mContext);
173        mTouchSlop = configuration.getScaledTouchSlop();
174
175        mSGD = new ScaleGestureDetector(context, mScaleGestureListener);
176    }
177
178    private void updateExpansion() {
179        if (DEBUG_SCALE) Log.v(TAG, "updateExpansion()");
180        // are we scaling or dragging?
181        float span = mSGD.getCurrentSpan() - mInitialTouchSpan;
182        span *= USE_SPAN ? 1f : 0f;
183        float drag = mSGD.getFocusY() - mInitialTouchFocusY;
184        drag *= USE_DRAG ? 1f : 0f;
185        drag *= mGravity == Gravity.BOTTOM ? -1f : 1f;
186        float pull = Math.abs(drag) + Math.abs(span) + 1f;
187        float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull;
188        float target = hand + mOldHeight;
189        float newHeight = clamp(target);
190        mScaler.setHeight(newHeight);
191
192        mLastFocusY = mSGD.getFocusY();
193        mLastSpanY = mSGD.getCurrentSpan();
194    }
195
196    private float clamp(float target) {
197        float out = target;
198        out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out);
199        out = out > mNaturalHeight ? mNaturalHeight : out;
200        return out;
201    }
202
203    private ExpandableView findView(float x, float y) {
204        ExpandableView v;
205        if (mEventSource != null) {
206            int[] location = new int[2];
207            mEventSource.getLocationOnScreen(location);
208            x += location[0];
209            y += location[1];
210            v = mCallback.getChildAtRawPosition(x, y);
211        } else {
212            v = mCallback.getChildAtPosition(x, y);
213        }
214        return v;
215    }
216
217    private boolean isInside(View v, float x, float y) {
218        if (DEBUG) Log.d(TAG, "isinside (" + x + ", " + y + ")");
219
220        if (v == null) {
221            if (DEBUG) Log.d(TAG, "isinside null subject");
222            return false;
223        }
224        if (mEventSource != null) {
225            int[] location = new int[2];
226            mEventSource.getLocationOnScreen(location);
227            x += location[0];
228            y += location[1];
229            if (DEBUG) Log.d(TAG, "  to global (" + x + ", " + y + ")");
230        }
231        int[] location = new int[2];
232        v.getLocationOnScreen(location);
233        x -= location[0];
234        y -= location[1];
235        if (DEBUG) Log.d(TAG, "  to local (" + x + ", " + y + ")");
236        if (DEBUG) Log.d(TAG, "  inside (" + v.getWidth() + ", " + v.getHeight() + ")");
237        boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight());
238        return inside;
239    }
240
241    public void setEventSource(View eventSource) {
242        mEventSource = eventSource;
243    }
244
245    public void setGravity(int gravity) {
246        mGravity = gravity;
247    }
248
249    public void setScrollAdapter(ScrollAdapter adapter) {
250        mScrollAdapter = adapter;
251    }
252
253    @Override
254    public boolean onInterceptTouchEvent(MotionEvent ev) {
255        final int action = ev.getAction();
256        if (DEBUG_SCALE) Log.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) +
257                         " expanding=" + mExpanding +
258                         (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
259                         (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
260                         (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
261        // check for a spread-finger vertical pull gesture
262        mSGD.onTouchEvent(ev);
263        final int x = (int) mSGD.getFocusX();
264        final int y = (int) mSGD.getFocusY();
265
266        mInitialTouchFocusY = y;
267        mInitialTouchSpan = mSGD.getCurrentSpan();
268        mLastFocusY = mInitialTouchFocusY;
269        mLastSpanY = mInitialTouchSpan;
270        if (DEBUG_SCALE) Log.d(TAG, "set initial span: " + mInitialTouchSpan);
271
272        if (mExpanding) {
273            return true;
274        } else {
275            if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) {
276                // we've begun Venetian blinds style expansion
277                return true;
278            }
279            final float xspan = mSGD.getCurrentSpanX();
280            if ((action == MotionEvent.ACTION_MOVE &&
281                    xspan > mPullGestureMinXSpan &&
282                    xspan > mSGD.getCurrentSpanY())) {
283                // detect a vertical pulling gesture with fingers somewhat separated
284                if (DEBUG_SCALE) Log.v(TAG, "got pull gesture (xspan=" + xspan + "px)");
285
286                final ExpandableView underFocus = findView(x, y);
287                startExpanding(underFocus, PULL);
288                return true;
289            }
290            if (mScrollAdapter != null && !mScrollAdapter.isScrolledToTop()) {
291                return false;
292            }
293            // Now look for other gestures
294            switch (action & MotionEvent.ACTION_MASK) {
295            case MotionEvent.ACTION_MOVE: {
296                if (mWatchingForPull) {
297                    final int yDiff = y - mLastMotionY;
298                    if (yDiff > mTouchSlop) {
299                        if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)");
300                        mLastMotionY = y;
301                        final ExpandableView underFocus = findView(x, y);
302                        if (startExpanding(underFocus, BLINDS)) {
303                            mInitialTouchY = mLastMotionY;
304                            mHasPopped = false;
305                        }
306                    }
307                }
308                break;
309            }
310
311            case MotionEvent.ACTION_DOWN:
312                mWatchingForPull = mScrollAdapter != null &&
313                        isInside(mScrollAdapter.getHostView(), x, y);
314                mLastMotionY = y;
315                break;
316
317            case MotionEvent.ACTION_CANCEL:
318            case MotionEvent.ACTION_UP:
319                if (DEBUG) Log.d(TAG, "up/cancel");
320                finishExpanding(false);
321                clearView();
322                break;
323            }
324            return mExpanding;
325        }
326    }
327
328    @Override
329    public boolean onTouchEvent(MotionEvent ev) {
330        final int action = ev.getActionMasked();
331        if (DEBUG_SCALE) Log.d(TAG, "touch: act=" + MotionEvent.actionToString(action) +
332                " expanding=" + mExpanding +
333                (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
334                (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
335                (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
336
337        mSGD.onTouchEvent(ev);
338
339        switch (action) {
340            case MotionEvent.ACTION_MOVE: {
341                if (0 != (mExpansionStyle & BLINDS)) {
342                    final float rawHeight = ev.getY() - mInitialTouchY + mOldHeight;
343                    final float newHeight = clamp(rawHeight);
344                    final boolean wasClosed = (mOldHeight == mSmallSize);
345                    boolean isFinished = false;
346                    if (rawHeight > mNaturalHeight) {
347                        isFinished = true;
348                    }
349                    if (rawHeight < mSmallSize) {
350                        isFinished = true;
351                    }
352
353                    final float pull = Math.abs(ev.getY() - mInitialTouchY);
354                    if (mHasPopped || pull > mPopLimit) {
355                        if (!mHasPopped) {
356                            vibrate(mPopDuration);
357                            mHasPopped = true;
358                        }
359                    }
360
361                    if (mHasPopped) {
362                        mScaler.setHeight(newHeight);
363                    }
364
365                    final int x = (int) mSGD.getFocusX();
366                    final int y = (int) mSGD.getFocusY();
367                    ExpandableView underFocus = findView(x, y);
368                    if (isFinished && underFocus != null && underFocus != mCurrView) {
369                        finishExpanding(false); // @@@ needed?
370                        startExpanding(underFocus, BLINDS);
371                        mInitialTouchY = y;
372                        mHasPopped = false;
373                    }
374                    return true;
375                }
376
377                if (mExpanding) {
378                    updateExpansion();
379                    return true;
380                }
381
382                break;
383            }
384
385            case MotionEvent.ACTION_POINTER_UP:
386            case MotionEvent.ACTION_POINTER_DOWN:
387                if (DEBUG) Log.d(TAG, "pointer change");
388                mInitialTouchY += mSGD.getFocusY() - mLastFocusY;
389                mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY;
390                break;
391
392            case MotionEvent.ACTION_UP:
393            case MotionEvent.ACTION_CANCEL:
394                if (DEBUG) Log.d(TAG, "up/cancel");
395                finishExpanding(false);
396                clearView();
397                break;
398        }
399        return true;
400    }
401
402    /**
403     * @return True if the view is expandable, false otherwise.
404     */
405    private boolean startExpanding(ExpandableView v, int expandType) {
406        if (!(v instanceof ExpandableNotificationRow)) {
407            return false;
408        }
409        mExpansionStyle = expandType;
410        if (mExpanding && v == mCurrView) {
411            return true;
412        }
413        mExpanding = true;
414        if (DEBUG) Log.d(TAG, "scale type " + expandType + " beginning on view: " + v);
415        mCallback.setUserLockedChild(v, true);
416        setView(v);
417        mScaler.setView((ExpandableView) v);
418        mOldHeight = mScaler.getHeight();
419        if (mCallback.canChildBeExpanded(v)) {
420            if (DEBUG) Log.d(TAG, "working on an expandable child");
421            mNaturalHeight = mScaler.getNaturalHeight(mLargeSize);
422        } else {
423            if (DEBUG) Log.d(TAG, "working on a non-expandable child");
424            mNaturalHeight = mOldHeight;
425        }
426        if (DEBUG) Log.d(TAG, "got mOldHeight: " + mOldHeight +
427                    " mNaturalHeight: " + mNaturalHeight);
428        v.getParent().requestDisallowInterceptTouchEvent(true);
429        return true;
430    }
431
432    private void finishExpanding(boolean force) {
433        if (!mExpanding) return;
434
435        if (DEBUG) Log.d(TAG, "scale in finishing on view: " + mCurrView);
436
437        float currentHeight = mScaler.getHeight();
438        float targetHeight = mSmallSize;
439        float h = mScaler.getHeight();
440        final boolean wasClosed = (mOldHeight == mSmallSize);
441        if (wasClosed) {
442            targetHeight = (force || currentHeight > mSmallSize) ? mNaturalHeight : mSmallSize;
443        } else {
444            targetHeight = (force || currentHeight < mNaturalHeight) ? mSmallSize : mNaturalHeight;
445        }
446        if (mScaleAnimation.isRunning()) {
447            mScaleAnimation.cancel();
448        }
449        mCallback.setUserExpandedChild(mCurrView, targetHeight == mNaturalHeight);
450        if (targetHeight != currentHeight) {
451            mScaleAnimation.setFloatValues(targetHeight);
452            mScaleAnimation.setupStartValues();
453            final View scaledView = mCurrView;
454            mScaleAnimation.addListener(new AnimatorListenerAdapter() {
455                @Override
456                public void onAnimationEnd(Animator animation) {
457                    mCallback.setUserLockedChild(scaledView, false);
458                    mScaleAnimation.removeListener(this);
459                }
460            });
461            mScaleAnimation.start();
462        } else {
463            mCallback.setUserLockedChild(mCurrView, false);
464        }
465
466        mExpanding = false;
467        mExpansionStyle = NONE;
468
469        if (DEBUG) Log.d(TAG, "wasClosed is: " + wasClosed);
470        if (DEBUG) Log.d(TAG, "currentHeight is: " + currentHeight);
471        if (DEBUG) Log.d(TAG, "mSmallSize is: " + mSmallSize);
472        if (DEBUG) Log.d(TAG, "targetHeight is: " + targetHeight);
473        if (DEBUG) Log.d(TAG, "scale was finished on view: " + mCurrView);
474    }
475
476    private void clearView() {
477        mCurrView = null;
478
479    }
480
481    private void setView(View v) {
482        mCurrView = v;
483    }
484
485    /**
486     * Use this to abort any pending expansions in progress.
487     */
488    public void cancel() {
489        finishExpanding(true);
490        clearView();
491
492        // reset the gesture detector
493        mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener);
494    }
495
496    /**
497     * Triggers haptic feedback.
498     */
499    private synchronized void vibrate(long duration) {
500        if (mVibrator == null) {
501            mVibrator = (android.os.Vibrator)
502                    mContext.getSystemService(Context.VIBRATOR_SERVICE);
503        }
504        mVibrator.vibrate(duration, AudioManager.STREAM_SYSTEM);
505    }
506}
507
508