ExpandHelper.java revision b5605e58cb8080c8c887b1885336b707596c8094
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 18package com.android.systemui; 19 20import android.animation.Animator; 21import android.animation.AnimatorListenerAdapter; 22import android.animation.ObjectAnimator; 23import android.content.Context; 24import android.media.AudioAttributes; 25import android.os.Vibrator; 26import android.util.Log; 27import android.view.Gravity; 28import android.view.MotionEvent; 29import android.view.ScaleGestureDetector; 30import android.view.ScaleGestureDetector.OnScaleGestureListener; 31import android.view.VelocityTracker; 32import android.view.View; 33import android.view.ViewConfiguration; 34 35import com.android.systemui.statusbar.ExpandableNotificationRow; 36import com.android.systemui.statusbar.ExpandableView; 37import com.android.systemui.statusbar.FlingAnimationUtils; 38import com.android.systemui.statusbar.policy.ScrollAdapter; 39 40public class ExpandHelper implements Gefingerpoken { 41 public interface Callback { 42 ExpandableView getChildAtRawPosition(float x, float y); 43 ExpandableView getChildAtPosition(float x, float y); 44 boolean canChildBeExpanded(View v); 45 void setUserExpandedChild(View v, boolean userExpanded); 46 void setUserLockedChild(View v, boolean userLocked); 47 void expansionStateChanged(boolean isExpanding); 48 } 49 50 private static final String TAG = "ExpandHelper"; 51 protected static final boolean DEBUG = false; 52 protected static final boolean DEBUG_SCALE = false; 53 private static final float EXPAND_DURATION = 0.3f; 54 55 // Set to false to disable focus-based gestures (spread-finger vertical pull). 56 private static final boolean USE_DRAG = true; 57 // Set to false to disable scale-based gestures (both horizontal and vertical). 58 private static final boolean USE_SPAN = true; 59 // Both gestures types may be active at the same time. 60 // At least one gesture type should be active. 61 // A variant of the screwdriver gesture will emerge from either gesture type. 62 63 // amount of overstretch for maximum brightness expressed in U 64 // 2f: maximum brightness is stretching a 1U to 3U, or a 4U to 6U 65 private static final float STRETCH_INTERVAL = 2f; 66 67 // level of glow for a touch, without overstretch 68 // overstretch fills the range (GLOW_BASE, 1.0] 69 private static final float GLOW_BASE = 0.5f; 70 71 private static final AudioAttributes VIBRATION_ATTRIBUTES = new AudioAttributes.Builder() 72 .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) 73 .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) 74 .build(); 75 76 @SuppressWarnings("unused") 77 private Context mContext; 78 79 private boolean mExpanding; 80 private static final int NONE = 0; 81 private static final int BLINDS = 1<<0; 82 private static final int PULL = 1<<1; 83 private static final int STRETCH = 1<<2; 84 private int mExpansionStyle = NONE; 85 private boolean mWatchingForPull; 86 private boolean mHasPopped; 87 private View mEventSource; 88 private float mOldHeight; 89 private float mNaturalHeight; 90 private float mInitialTouchFocusY; 91 private float mInitialTouchY; 92 private float mInitialTouchSpan; 93 private float mLastFocusY; 94 private float mLastSpanY; 95 private int mTouchSlop; 96 private float mLastMotionY; 97 private int mPopDuration; 98 private float mPullGestureMinXSpan; 99 private Callback mCallback; 100 private ScaleGestureDetector mSGD; 101 private ViewScaler mScaler; 102 private ObjectAnimator mScaleAnimation; 103 private Vibrator mVibrator; 104 private boolean mEnabled = true; 105 private ExpandableView mResizedView; 106 private float mCurrentHeight; 107 108 private int mSmallSize; 109 private int mLargeSize; 110 private float mMaximumStretch; 111 private boolean mOnlyMovements; 112 113 private int mGravity; 114 115 private ScrollAdapter mScrollAdapter; 116 private FlingAnimationUtils mFlingAnimationUtils; 117 private VelocityTracker mVelocityTracker; 118 119 private OnScaleGestureListener mScaleGestureListener 120 = new ScaleGestureDetector.SimpleOnScaleGestureListener() { 121 @Override 122 public boolean onScaleBegin(ScaleGestureDetector detector) { 123 if (DEBUG_SCALE) Log.v(TAG, "onscalebegin()"); 124 125 startExpanding(mResizedView, STRETCH); 126 return mExpanding; 127 } 128 129 @Override 130 public boolean onScale(ScaleGestureDetector detector) { 131 if (DEBUG_SCALE) Log.v(TAG, "onscale() on " + mResizedView); 132 return true; 133 } 134 135 @Override 136 public void onScaleEnd(ScaleGestureDetector detector) { 137 } 138 }; 139 140 private class ViewScaler { 141 ExpandableView mView; 142 143 public ViewScaler() {} 144 public void setView(ExpandableView v) { 145 mView = v; 146 } 147 public void setHeight(float h) { 148 if (DEBUG_SCALE) Log.v(TAG, "SetHeight: setting to " + h); 149 mView.setContentHeight((int) h); 150 mCurrentHeight = h; 151 } 152 public float getHeight() { 153 return mView.getContentHeight(); 154 } 155 public int getNaturalHeight(int maximum) { 156 return Math.min(maximum, mView.getMaxContentHeight()); 157 } 158 } 159 160 /** 161 * Handle expansion gestures to expand and contract children of the callback. 162 * 163 * @param context application context 164 * @param callback the container that holds the items to be manipulated 165 * @param small the smallest allowable size for the manuipulated items. 166 * @param large the largest allowable size for the manuipulated items. 167 */ 168 public ExpandHelper(Context context, Callback callback, int small, int large) { 169 mSmallSize = small; 170 mMaximumStretch = mSmallSize * STRETCH_INTERVAL; 171 mLargeSize = large; 172 mContext = context; 173 mCallback = callback; 174 mScaler = new ViewScaler(); 175 mGravity = Gravity.TOP; 176 mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f); 177 mPopDuration = mContext.getResources().getInteger(R.integer.blinds_pop_duration_ms); 178 mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min); 179 180 final ViewConfiguration configuration = ViewConfiguration.get(mContext); 181 mTouchSlop = configuration.getScaledTouchSlop(); 182 183 mSGD = new ScaleGestureDetector(context, mScaleGestureListener); 184 mFlingAnimationUtils = new FlingAnimationUtils(context, EXPAND_DURATION); 185 } 186 187 private void updateExpansion() { 188 if (DEBUG_SCALE) Log.v(TAG, "updateExpansion()"); 189 // are we scaling or dragging? 190 float span = mSGD.getCurrentSpan() - mInitialTouchSpan; 191 span *= USE_SPAN ? 1f : 0f; 192 float drag = mSGD.getFocusY() - mInitialTouchFocusY; 193 drag *= USE_DRAG ? 1f : 0f; 194 drag *= mGravity == Gravity.BOTTOM ? -1f : 1f; 195 float pull = Math.abs(drag) + Math.abs(span) + 1f; 196 float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull; 197 float target = hand + mOldHeight; 198 float newHeight = clamp(target); 199 mScaler.setHeight(newHeight); 200 mLastFocusY = mSGD.getFocusY(); 201 mLastSpanY = mSGD.getCurrentSpan(); 202 } 203 204 private float clamp(float target) { 205 float out = target; 206 out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out); 207 out = out > mNaturalHeight ? mNaturalHeight : out; 208 return out; 209 } 210 211 private ExpandableView findView(float x, float y) { 212 ExpandableView v; 213 if (mEventSource != null) { 214 int[] location = new int[2]; 215 mEventSource.getLocationOnScreen(location); 216 x += location[0]; 217 y += location[1]; 218 v = mCallback.getChildAtRawPosition(x, y); 219 } else { 220 v = mCallback.getChildAtPosition(x, y); 221 } 222 return v; 223 } 224 225 private boolean isInside(View v, float x, float y) { 226 if (DEBUG) Log.d(TAG, "isinside (" + x + ", " + y + ")"); 227 228 if (v == null) { 229 if (DEBUG) Log.d(TAG, "isinside null subject"); 230 return false; 231 } 232 if (mEventSource != null) { 233 int[] location = new int[2]; 234 mEventSource.getLocationOnScreen(location); 235 x += location[0]; 236 y += location[1]; 237 if (DEBUG) Log.d(TAG, " to global (" + x + ", " + y + ")"); 238 } 239 int[] location = new int[2]; 240 v.getLocationOnScreen(location); 241 x -= location[0]; 242 y -= location[1]; 243 if (DEBUG) Log.d(TAG, " to local (" + x + ", " + y + ")"); 244 if (DEBUG) Log.d(TAG, " inside (" + v.getWidth() + ", " + v.getHeight() + ")"); 245 boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight()); 246 return inside; 247 } 248 249 public void setEventSource(View eventSource) { 250 mEventSource = eventSource; 251 } 252 253 public void setGravity(int gravity) { 254 mGravity = gravity; 255 } 256 257 public void setScrollAdapter(ScrollAdapter adapter) { 258 mScrollAdapter = adapter; 259 } 260 261 @Override 262 public boolean onInterceptTouchEvent(MotionEvent ev) { 263 if (!isEnabled()) { 264 return false; 265 } 266 trackVelocity(ev); 267 final int action = ev.getAction(); 268 if (DEBUG_SCALE) Log.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) + 269 " expanding=" + mExpanding + 270 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 271 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 272 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 273 // check for a spread-finger vertical pull gesture 274 mSGD.onTouchEvent(ev); 275 final int x = (int) mSGD.getFocusX(); 276 final int y = (int) mSGD.getFocusY(); 277 278 mInitialTouchFocusY = y; 279 mInitialTouchSpan = mSGD.getCurrentSpan(); 280 mLastFocusY = mInitialTouchFocusY; 281 mLastSpanY = mInitialTouchSpan; 282 if (DEBUG_SCALE) Log.d(TAG, "set initial span: " + mInitialTouchSpan); 283 284 if (mExpanding) { 285 mLastMotionY = ev.getRawY(); 286 maybeRecycleVelocityTracker(ev); 287 return true; 288 } else { 289 if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) { 290 // we've begun Venetian blinds style expansion 291 return true; 292 } 293 switch (action & MotionEvent.ACTION_MASK) { 294 case MotionEvent.ACTION_MOVE: { 295 final float xspan = mSGD.getCurrentSpanX(); 296 if (xspan > mPullGestureMinXSpan && 297 xspan > mSGD.getCurrentSpanY() && !mExpanding) { 298 // detect a vertical pulling gesture with fingers somewhat separated 299 if (DEBUG_SCALE) Log.v(TAG, "got pull gesture (xspan=" + xspan + "px)"); 300 startExpanding(mResizedView, PULL); 301 mWatchingForPull = false; 302 } 303 if (mWatchingForPull) { 304 final float yDiff = ev.getRawY() - mInitialTouchY; 305 if (yDiff > mTouchSlop) { 306 if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)"); 307 mWatchingForPull = false; 308 if (mResizedView != null && !isFullyExpanded(mResizedView)) { 309 if (startExpanding(mResizedView, BLINDS)) { 310 mLastMotionY = ev.getRawY(); 311 mInitialTouchY = ev.getRawY(); 312 mHasPopped = false; 313 } 314 } 315 } 316 } 317 break; 318 } 319 320 case MotionEvent.ACTION_DOWN: 321 mWatchingForPull = mScrollAdapter != null && 322 isInside(mScrollAdapter.getHostView(), x, y) 323 && mScrollAdapter.isScrolledToTop(); 324 mResizedView = findView(x, y); 325 mInitialTouchY = ev.getY(); 326 break; 327 328 case MotionEvent.ACTION_CANCEL: 329 case MotionEvent.ACTION_UP: 330 if (DEBUG) Log.d(TAG, "up/cancel"); 331 finishExpanding(false, getCurrentVelocity()); 332 clearView(); 333 break; 334 } 335 mLastMotionY = ev.getRawY(); 336 maybeRecycleVelocityTracker(ev); 337 return mExpanding; 338 } 339 } 340 341 private void trackVelocity(MotionEvent event) { 342 int action = event.getActionMasked(); 343 switch(action) { 344 case MotionEvent.ACTION_DOWN: 345 if (mVelocityTracker == null) { 346 mVelocityTracker = VelocityTracker.obtain(); 347 } else { 348 mVelocityTracker.clear(); 349 } 350 mVelocityTracker.addMovement(event); 351 break; 352 case MotionEvent.ACTION_MOVE: 353 if (mVelocityTracker == null) { 354 mVelocityTracker = VelocityTracker.obtain(); 355 } 356 mVelocityTracker.addMovement(event); 357 break; 358 default: 359 break; 360 } 361 } 362 363 private void maybeRecycleVelocityTracker(MotionEvent event) { 364 if (mVelocityTracker != null && (event.getActionMasked() == MotionEvent.ACTION_CANCEL 365 || event.getActionMasked() == MotionEvent.ACTION_UP)) { 366 mVelocityTracker.recycle(); 367 mVelocityTracker = null; 368 } 369 } 370 371 private float getCurrentVelocity() { 372 if (mVelocityTracker != null) { 373 mVelocityTracker.computeCurrentVelocity(1000); 374 return mVelocityTracker.getYVelocity(); 375 } else { 376 return 0f; 377 } 378 } 379 380 public void setEnabled(boolean enable) { 381 mEnabled = enable; 382 } 383 384 private boolean isEnabled() { 385 return mEnabled; 386 } 387 388 private boolean isFullyExpanded(ExpandableView underFocus) { 389 return underFocus.areChildrenExpanded() || underFocus.getIntrinsicHeight() 390 - underFocus.getBottomDecorHeight() == underFocus.getMaxContentHeight(); 391 } 392 393 @Override 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 */ 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 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 573 private void clearView() { 574 mResizedView = null; 575 } 576 577 /** 578 * Use this to abort any pending expansions in progress. 579 */ 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 */ 596 public void onlyObserveMovements(boolean onlyMovements) { 597 mOnlyMovements = onlyMovements; 598 } 599 600 /** 601 * Triggers haptic feedback. 602 */ 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