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