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 { 43 ExpandableView getChildAtRawPosition(float x, float y); 44 ExpandableView getChildAtPosition(float x, float y); 45 boolean canChildBeExpanded(View v); 46 void setUserExpandedChild(View v, boolean userExpanded); 47 void setUserLockedChild(View v, boolean userLocked); 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 133 public ViewScaler() {} 134 public void setView(ExpandableView v) { 135 mView = v; 136 } 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 } 142 public float getHeight() { 143 return mView.getContentHeight(); 144 } 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 */ 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 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 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 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 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 238 public void setEventSource(View eventSource) { 239 mEventSource = eventSource; 240 } 241 242 public void setGravity(int gravity) { 243 mGravity = gravity; 244 } 245 246 public void setScrollAdapter(ScrollAdapter adapter) { 247 mScrollAdapter = adapter; 248 } 249 250 @Override 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 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 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 364 private float getCurrentVelocity() { 365 if (mVelocityTracker != null) { 366 mVelocityTracker.computeCurrentVelocity(1000); 367 return mVelocityTracker.getYVelocity(); 368 } else { 369 return 0f; 370 } 371 } 372 373 public void setEnabled(boolean enable) { 374 mEnabled = enable; 375 } 376 377 private boolean isEnabled() { 378 return mEnabled; 379 } 380 381 private boolean isFullyExpanded(ExpandableView underFocus) { 382 return underFocus.areChildrenExpanded() || underFocus.getIntrinsicHeight() 383 - underFocus.getBottomDecorHeight() == underFocus.getMaxContentHeight(); 384 } 385 386 @Override 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 */ 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 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 568 private void clearView() { 569 mResizedView = null; 570 } 571 572 /** 573 * Use this to abort any pending expansions in progress. 574 */ 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 */ 591 public void onlyObserveMovements(boolean onlyMovements) { 592 mOnlyMovements = onlyMovements; 593 } 594 } 595 596