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