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.util.Log; 25 import android.view.Gravity; 26 import android.view.HapticFeedbackConstants; 27 import android.view.MotionEvent; 28 import android.view.ScaleGestureDetector; 29 import android.view.ScaleGestureDetector.OnScaleGestureListener; 30 import android.view.VelocityTracker; 31 import android.view.View; 32 import android.view.ViewConfiguration; 33 34 import com.android.systemui.statusbar.ExpandableNotificationRow; 35 import com.android.systemui.statusbar.ExpandableView; 36 import com.android.systemui.statusbar.FlingAnimationUtils; 37 import com.android.systemui.statusbar.policy.ScrollAdapter; 38 39 public class ExpandHelper implements Gefingerpoken { 40 public interface Callback { 41 ExpandableView getChildAtRawPosition(float x, float y); 42 ExpandableView getChildAtPosition(float x, float y); 43 boolean canChildBeExpanded(View v); 44 void setUserExpandedChild(View v, boolean userExpanded); 45 void setUserLockedChild(View v, boolean userLocked); 46 void expansionStateChanged(boolean isExpanding); 47 int getMaxExpandHeight(ExpandableView view); 48 void setExpansionCancelled(View view); 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 mInitialTouchX; 84 private float mInitialTouchY; 85 private float mInitialTouchSpan; 86 private float mLastFocusY; 87 private float mLastSpanY; 88 private int mTouchSlop; 89 private float mLastMotionY; 90 private float mPullGestureMinXSpan; 91 private Callback mCallback; 92 private ScaleGestureDetector mSGD; 93 private ViewScaler mScaler; 94 private ObjectAnimator mScaleAnimation; 95 private boolean mEnabled = true; 96 private ExpandableView mResizedView; 97 private float mCurrentHeight; 98 99 private int mSmallSize; 100 private int mLargeSize; 101 private float mMaximumStretch; 102 private boolean mOnlyMovements; 103 104 private int mGravity; 105 106 private ScrollAdapter mScrollAdapter; 107 private FlingAnimationUtils mFlingAnimationUtils; 108 private VelocityTracker mVelocityTracker; 109 110 private OnScaleGestureListener mScaleGestureListener 111 = new ScaleGestureDetector.SimpleOnScaleGestureListener() { 112 @Override 113 public boolean onScaleBegin(ScaleGestureDetector detector) { 114 if (DEBUG_SCALE) Log.v(TAG, "onscalebegin()"); 115 116 if (!mOnlyMovements) { 117 startExpanding(mResizedView, STRETCH); 118 } 119 return mExpanding; 120 } 121 122 @Override 123 public boolean onScale(ScaleGestureDetector detector) { 124 if (DEBUG_SCALE) Log.v(TAG, "onscale() on " + mResizedView); 125 return true; 126 } 127 128 @Override 129 public void onScaleEnd(ScaleGestureDetector detector) { 130 } 131 }; 132 133 private class ViewScaler { 134 ExpandableView mView; 135 136 public ViewScaler() {} 137 public void setView(ExpandableView v) { 138 mView = v; 139 } 140 public void setHeight(float h) { 141 if (DEBUG_SCALE) Log.v(TAG, "SetHeight: setting to " + h); 142 mView.setActualHeight((int) h); 143 mCurrentHeight = h; 144 } 145 public float getHeight() { 146 return mView.getActualHeight(); 147 } 148 public int getNaturalHeight() { 149 return mCallback.getMaxExpandHeight(mView); 150 } 151 } 152 153 /** 154 * Handle expansion gestures to expand and contract children of the callback. 155 * 156 * @param context application context 157 * @param callback the container that holds the items to be manipulated 158 * @param small the smallest allowable size for the manuipulated items. 159 * @param large the largest allowable size for the manuipulated items. 160 */ 161 public ExpandHelper(Context context, Callback callback, int small, int large) { 162 mSmallSize = small; 163 mMaximumStretch = mSmallSize * STRETCH_INTERVAL; 164 mLargeSize = large; 165 mContext = context; 166 mCallback = callback; 167 mScaler = new ViewScaler(); 168 mGravity = Gravity.TOP; 169 mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f); 170 mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min); 171 172 final ViewConfiguration configuration = ViewConfiguration.get(mContext); 173 mTouchSlop = configuration.getScaledTouchSlop(); 174 175 mSGD = new ScaleGestureDetector(context, mScaleGestureListener); 176 mFlingAnimationUtils = new FlingAnimationUtils(context, EXPAND_DURATION); 177 } 178 179 private void updateExpansion() { 180 if (DEBUG_SCALE) Log.v(TAG, "updateExpansion()"); 181 // are we scaling or dragging? 182 float span = mSGD.getCurrentSpan() - mInitialTouchSpan; 183 span *= USE_SPAN ? 1f : 0f; 184 float drag = mSGD.getFocusY() - mInitialTouchFocusY; 185 drag *= USE_DRAG ? 1f : 0f; 186 drag *= mGravity == Gravity.BOTTOM ? -1f : 1f; 187 float pull = Math.abs(drag) + Math.abs(span) + 1f; 188 float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull; 189 float target = hand + mOldHeight; 190 float newHeight = clamp(target); 191 mScaler.setHeight(newHeight); 192 mLastFocusY = mSGD.getFocusY(); 193 mLastSpanY = mSGD.getCurrentSpan(); 194 } 195 196 private float clamp(float target) { 197 float out = target; 198 out = out < mSmallSize ? mSmallSize : out; 199 out = out > mNaturalHeight ? mNaturalHeight : out; 200 return out; 201 } 202 203 private ExpandableView findView(float x, float y) { 204 ExpandableView v; 205 if (mEventSource != null) { 206 int[] location = new int[2]; 207 mEventSource.getLocationOnScreen(location); 208 x += location[0]; 209 y += location[1]; 210 v = mCallback.getChildAtRawPosition(x, y); 211 } else { 212 v = mCallback.getChildAtPosition(x, y); 213 } 214 return v; 215 } 216 217 private boolean isInside(View v, float x, float y) { 218 if (DEBUG) Log.d(TAG, "isinside (" + x + ", " + y + ")"); 219 220 if (v == null) { 221 if (DEBUG) Log.d(TAG, "isinside null subject"); 222 return false; 223 } 224 if (mEventSource != null) { 225 int[] location = new int[2]; 226 mEventSource.getLocationOnScreen(location); 227 x += location[0]; 228 y += location[1]; 229 if (DEBUG) Log.d(TAG, " to global (" + x + ", " + y + ")"); 230 } 231 int[] location = new int[2]; 232 v.getLocationOnScreen(location); 233 x -= location[0]; 234 y -= location[1]; 235 if (DEBUG) Log.d(TAG, " to local (" + x + ", " + y + ")"); 236 if (DEBUG) Log.d(TAG, " inside (" + v.getWidth() + ", " + v.getHeight() + ")"); 237 boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight()); 238 return inside; 239 } 240 241 public void setEventSource(View eventSource) { 242 mEventSource = eventSource; 243 } 244 245 public void setGravity(int gravity) { 246 mGravity = gravity; 247 } 248 249 public void setScrollAdapter(ScrollAdapter adapter) { 250 mScrollAdapter = adapter; 251 } 252 253 @Override 254 public boolean onInterceptTouchEvent(MotionEvent ev) { 255 if (!isEnabled()) { 256 return false; 257 } 258 trackVelocity(ev); 259 final int action = ev.getAction(); 260 if (DEBUG_SCALE) Log.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) + 261 " expanding=" + mExpanding + 262 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 263 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 264 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 265 // check for a spread-finger vertical pull gesture 266 mSGD.onTouchEvent(ev); 267 final int x = (int) mSGD.getFocusX(); 268 final int y = (int) mSGD.getFocusY(); 269 270 mInitialTouchFocusY = y; 271 mInitialTouchSpan = mSGD.getCurrentSpan(); 272 mLastFocusY = mInitialTouchFocusY; 273 mLastSpanY = mInitialTouchSpan; 274 if (DEBUG_SCALE) Log.d(TAG, "set initial span: " + mInitialTouchSpan); 275 276 if (mExpanding) { 277 mLastMotionY = ev.getRawY(); 278 maybeRecycleVelocityTracker(ev); 279 return true; 280 } else { 281 if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) { 282 // we've begun Venetian blinds style expansion 283 return true; 284 } 285 switch (action & MotionEvent.ACTION_MASK) { 286 case MotionEvent.ACTION_MOVE: { 287 final float xspan = mSGD.getCurrentSpanX(); 288 if (xspan > mPullGestureMinXSpan && 289 xspan > mSGD.getCurrentSpanY() && !mExpanding) { 290 // detect a vertical pulling gesture with fingers somewhat separated 291 if (DEBUG_SCALE) Log.v(TAG, "got pull gesture (xspan=" + xspan + "px)"); 292 startExpanding(mResizedView, PULL); 293 mWatchingForPull = false; 294 } 295 if (mWatchingForPull) { 296 final float yDiff = ev.getRawY() - mInitialTouchY; 297 final float xDiff = ev.getRawX() - mInitialTouchX; 298 if (yDiff > mTouchSlop && yDiff > Math.abs(xDiff)) { 299 if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)"); 300 mWatchingForPull = false; 301 if (mResizedView != null && !isFullyExpanded(mResizedView)) { 302 if (startExpanding(mResizedView, BLINDS)) { 303 mLastMotionY = ev.getRawY(); 304 mInitialTouchY = ev.getRawY(); 305 mHasPopped = false; 306 } 307 } 308 } 309 } 310 break; 311 } 312 313 case MotionEvent.ACTION_DOWN: 314 mWatchingForPull = mScrollAdapter != null && 315 isInside(mScrollAdapter.getHostView(), x, y) 316 && mScrollAdapter.isScrolledToTop(); 317 mResizedView = findView(x, y); 318 if (mResizedView != null && !mCallback.canChildBeExpanded(mResizedView)) { 319 mResizedView = null; 320 mWatchingForPull = false; 321 } 322 mInitialTouchY = ev.getRawY(); 323 mInitialTouchX = ev.getRawX(); 324 break; 325 326 case MotionEvent.ACTION_CANCEL: 327 case MotionEvent.ACTION_UP: 328 if (DEBUG) Log.d(TAG, "up/cancel"); 329 finishExpanding(false, getCurrentVelocity()); 330 clearView(); 331 break; 332 } 333 mLastMotionY = ev.getRawY(); 334 maybeRecycleVelocityTracker(ev); 335 return mExpanding; 336 } 337 } 338 339 private void trackVelocity(MotionEvent event) { 340 int action = event.getActionMasked(); 341 switch(action) { 342 case MotionEvent.ACTION_DOWN: 343 if (mVelocityTracker == null) { 344 mVelocityTracker = VelocityTracker.obtain(); 345 } else { 346 mVelocityTracker.clear(); 347 } 348 mVelocityTracker.addMovement(event); 349 break; 350 case MotionEvent.ACTION_MOVE: 351 if (mVelocityTracker == null) { 352 mVelocityTracker = VelocityTracker.obtain(); 353 } 354 mVelocityTracker.addMovement(event); 355 break; 356 default: 357 break; 358 } 359 } 360 361 private void maybeRecycleVelocityTracker(MotionEvent event) { 362 if (mVelocityTracker != null && (event.getActionMasked() == MotionEvent.ACTION_CANCEL 363 || event.getActionMasked() == MotionEvent.ACTION_UP)) { 364 mVelocityTracker.recycle(); 365 mVelocityTracker = null; 366 } 367 } 368 369 private float getCurrentVelocity() { 370 if (mVelocityTracker != null) { 371 mVelocityTracker.computeCurrentVelocity(1000); 372 return mVelocityTracker.getYVelocity(); 373 } else { 374 return 0f; 375 } 376 } 377 378 public void setEnabled(boolean enable) { 379 mEnabled = enable; 380 } 381 382 private boolean isEnabled() { 383 return mEnabled; 384 } 385 386 private boolean isFullyExpanded(ExpandableView underFocus) { 387 return underFocus.getIntrinsicHeight() == underFocus.getMaxContentHeight() 388 && (!underFocus.isSummaryWithChildren() || underFocus.areChildrenExpanded()); 389 } 390 391 @Override 392 public boolean onTouchEvent(MotionEvent ev) { 393 if (!isEnabled()) { 394 return false; 395 } 396 trackVelocity(ev); 397 final int action = ev.getActionMasked(); 398 if (DEBUG_SCALE) Log.d(TAG, "touch: act=" + MotionEvent.actionToString(action) + 399 " expanding=" + mExpanding + 400 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 401 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 402 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 403 404 mSGD.onTouchEvent(ev); 405 final int x = (int) mSGD.getFocusX(); 406 final int y = (int) mSGD.getFocusY(); 407 408 if (mOnlyMovements) { 409 mLastMotionY = ev.getRawY(); 410 return false; 411 } 412 switch (action) { 413 case MotionEvent.ACTION_DOWN: 414 mWatchingForPull = mScrollAdapter != null && 415 isInside(mScrollAdapter.getHostView(), x, y); 416 mResizedView = findView(x, y); 417 mInitialTouchX = ev.getRawX(); 418 mInitialTouchY = ev.getRawY(); 419 break; 420 case MotionEvent.ACTION_MOVE: { 421 if (mWatchingForPull) { 422 final float yDiff = ev.getRawY() - mInitialTouchY; 423 final float xDiff = ev.getRawX() - mInitialTouchX; 424 if (yDiff > mTouchSlop && yDiff > Math.abs(xDiff)) { 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 if (mEventSource != null) { 452 mEventSource.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); 453 } 454 mHasPopped = true; 455 } 456 457 mScaler.setHeight(newHeight); 458 mLastMotionY = ev.getRawY(); 459 if (isFinished) { 460 mCallback.expansionStateChanged(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 */ 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 boolean canBeExpanded = mCallback.canChildBeExpanded(v); 516 if (canBeExpanded) { 517 if (DEBUG) Log.d(TAG, "working on an expandable child"); 518 mNaturalHeight = mScaler.getNaturalHeight(); 519 mSmallSize = v.getCollapsedHeight(); 520 } else { 521 if (DEBUG) Log.d(TAG, "working on a non-expandable child"); 522 mNaturalHeight = mOldHeight; 523 } 524 if (DEBUG) Log.d(TAG, "got mOldHeight: " + mOldHeight + 525 " mNaturalHeight: " + mNaturalHeight); 526 return true; 527 } 528 529 private void finishExpanding(boolean force, float velocity) { 530 if (!mExpanding) return; 531 532 if (DEBUG) Log.d(TAG, "scale in finishing on view: " + mResizedView); 533 534 float currentHeight = mScaler.getHeight(); 535 float h = mScaler.getHeight(); 536 final boolean wasClosed = (mOldHeight == mSmallSize); 537 boolean nowExpanded; 538 int naturalHeight = mScaler.getNaturalHeight(); 539 if (wasClosed) { 540 nowExpanded = (force || currentHeight > mOldHeight && velocity >= 0); 541 } else { 542 nowExpanded = !force && (currentHeight >= mOldHeight || velocity > 0); 543 } 544 nowExpanded |= mNaturalHeight == mSmallSize; 545 if (mScaleAnimation.isRunning()) { 546 mScaleAnimation.cancel(); 547 } 548 mCallback.expansionStateChanged(false); 549 float targetHeight = nowExpanded ? naturalHeight : mSmallSize; 550 if (targetHeight != currentHeight) { 551 mScaleAnimation.setFloatValues(targetHeight); 552 mScaleAnimation.setupStartValues(); 553 final View scaledView = mResizedView; 554 final boolean expand = nowExpanded; 555 mScaleAnimation.addListener(new AnimatorListenerAdapter() { 556 public boolean mCancelled; 557 558 @Override 559 public void onAnimationEnd(Animator animation) { 560 if (!mCancelled) { 561 mCallback.setUserExpandedChild(scaledView, expand); 562 } else { 563 mCallback.setExpansionCancelled(scaledView); 564 } 565 mCallback.setUserLockedChild(scaledView, false); 566 mScaleAnimation.removeListener(this); 567 } 568 569 @Override 570 public void onAnimationCancel(Animator animation) { 571 mCancelled = true; 572 } 573 }); 574 velocity = nowExpanded == velocity >= 0 ? velocity : 0; 575 mFlingAnimationUtils.apply(mScaleAnimation, currentHeight, targetHeight, velocity); 576 mScaleAnimation.start(); 577 } else { 578 mCallback.setUserExpandedChild(mResizedView, nowExpanded); 579 mCallback.setUserLockedChild(mResizedView, false); 580 } 581 582 mExpanding = false; 583 mExpansionStyle = NONE; 584 585 if (DEBUG) Log.d(TAG, "wasClosed is: " + wasClosed); 586 if (DEBUG) Log.d(TAG, "currentHeight is: " + currentHeight); 587 if (DEBUG) Log.d(TAG, "mSmallSize is: " + mSmallSize); 588 if (DEBUG) Log.d(TAG, "targetHeight is: " + targetHeight); 589 if (DEBUG) Log.d(TAG, "scale was finished on view: " + mResizedView); 590 } 591 592 private void clearView() { 593 mResizedView = null; 594 } 595 596 /** 597 * Use this to abort any pending expansions in progress. 598 */ 599 public void cancel() { 600 finishExpanding(true, 0f /* velocity */); 601 clearView(); 602 603 // reset the gesture detector 604 mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener); 605 } 606 607 /** 608 * Change the expansion mode to only observe movements and don't perform any resizing. 609 * This is needed when the expanding is finished and the scroller kicks in, 610 * performing an overscroll motion. We only want to shrink it again when we are not 611 * overscrolled. 612 * 613 * @param onlyMovements Should only movements be observed? 614 */ 615 public void onlyObserveMovements(boolean onlyMovements) { 616 mOnlyMovements = onlyMovements; 617 } 618 } 619 620