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