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 mVelocityTracker.addMovement(event); 355 break; 356 default: 357 break; 358 } 359 } 360 maybeRecycleVelocityTracker(MotionEvent event)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 getCurrentVelocity()369 private float getCurrentVelocity() { 370 if (mVelocityTracker != null) { 371 mVelocityTracker.computeCurrentVelocity(1000); 372 return mVelocityTracker.getYVelocity(); 373 } else { 374 return 0f; 375 } 376 } 377 setEnabled(boolean enable)378 public void setEnabled(boolean enable) { 379 mEnabled = enable; 380 } 381 isEnabled()382 private boolean isEnabled() { 383 return mEnabled; 384 } 385 isFullyExpanded(ExpandableView underFocus)386 private boolean isFullyExpanded(ExpandableView underFocus) { 387 return underFocus.getIntrinsicHeight() == underFocus.getMaxHeight(); 388 } 389 390 @Override onTouchEvent(MotionEvent ev)391 public boolean onTouchEvent(MotionEvent ev) { 392 if (!isEnabled()) { 393 return false; 394 } 395 trackVelocity(ev); 396 final int action = ev.getActionMasked(); 397 if (DEBUG_SCALE) Log.d(TAG, "touch: act=" + MotionEvent.actionToString(action) + 398 " expanding=" + mExpanding + 399 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 400 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 401 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 402 403 mSGD.onTouchEvent(ev); 404 final int x = (int) mSGD.getFocusX(); 405 final int y = (int) mSGD.getFocusY(); 406 407 if (mOnlyMovements) { 408 mLastMotionY = ev.getRawY(); 409 return false; 410 } 411 switch (action) { 412 case MotionEvent.ACTION_DOWN: 413 mWatchingForPull = mScrollAdapter != null && 414 isInside(mScrollAdapter.getHostView(), x, y); 415 mResizedView = findView(x, y); 416 mInitialTouchY = ev.getY(); 417 break; 418 case MotionEvent.ACTION_MOVE: { 419 if (mWatchingForPull) { 420 final float yDiff = ev.getRawY() - mInitialTouchY; 421 if (yDiff > mTouchSlop) { 422 if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)"); 423 mWatchingForPull = false; 424 if (mResizedView != null && !isFullyExpanded(mResizedView)) { 425 if (startExpanding(mResizedView, BLINDS)) { 426 mInitialTouchY = ev.getRawY(); 427 mLastMotionY = ev.getRawY(); 428 mHasPopped = false; 429 } 430 } 431 } 432 } 433 if (mExpanding && 0 != (mExpansionStyle & BLINDS)) { 434 final float rawHeight = ev.getRawY() - mLastMotionY + mCurrentHeight; 435 final float newHeight = clamp(rawHeight); 436 boolean isFinished = false; 437 boolean expanded = false; 438 if (rawHeight > mNaturalHeight) { 439 isFinished = true; 440 expanded = true; 441 } 442 if (rawHeight < mSmallSize) { 443 isFinished = true; 444 expanded = false; 445 } 446 447 if (!mHasPopped) { 448 vibrate(mPopDuration); 449 mHasPopped = true; 450 } 451 452 mScaler.setHeight(newHeight); 453 mLastMotionY = ev.getRawY(); 454 if (isFinished) { 455 mCallback.setUserExpandedChild(mResizedView, expanded); 456 mCallback.expansionStateChanged(false); 457 return false; 458 } else { 459 mCallback.expansionStateChanged(true); 460 } 461 return true; 462 } 463 464 if (mExpanding) { 465 466 // Gestural expansion is running 467 updateExpansion(); 468 mLastMotionY = ev.getRawY(); 469 return true; 470 } 471 472 break; 473 } 474 475 case MotionEvent.ACTION_POINTER_UP: 476 case MotionEvent.ACTION_POINTER_DOWN: 477 if (DEBUG) Log.d(TAG, "pointer change"); 478 mInitialTouchY += mSGD.getFocusY() - mLastFocusY; 479 mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY; 480 break; 481 482 case MotionEvent.ACTION_UP: 483 case MotionEvent.ACTION_CANCEL: 484 if (DEBUG) Log.d(TAG, "up/cancel"); 485 finishExpanding(false, getCurrentVelocity()); 486 clearView(); 487 break; 488 } 489 mLastMotionY = ev.getRawY(); 490 maybeRecycleVelocityTracker(ev); 491 return mResizedView != null; 492 } 493 494 /** 495 * @return True if the view is expandable, false otherwise. 496 */ startExpanding(ExpandableView v, int expandType)497 private boolean startExpanding(ExpandableView v, int expandType) { 498 if (!(v instanceof ExpandableNotificationRow)) { 499 return false; 500 } 501 mExpansionStyle = expandType; 502 if (mExpanding && v == mResizedView) { 503 return true; 504 } 505 mExpanding = true; 506 mCallback.expansionStateChanged(true); 507 if (DEBUG) Log.d(TAG, "scale type " + expandType + " beginning on view: " + v); 508 mCallback.setUserLockedChild(v, true); 509 mScaler.setView(v); 510 mOldHeight = mScaler.getHeight(); 511 mCurrentHeight = mOldHeight; 512 if (mCallback.canChildBeExpanded(v)) { 513 if (DEBUG) Log.d(TAG, "working on an expandable child"); 514 mNaturalHeight = mScaler.getNaturalHeight(mLargeSize); 515 } else { 516 if (DEBUG) Log.d(TAG, "working on a non-expandable child"); 517 mNaturalHeight = mOldHeight; 518 } 519 if (DEBUG) Log.d(TAG, "got mOldHeight: " + mOldHeight + 520 " mNaturalHeight: " + mNaturalHeight); 521 return true; 522 } 523 finishExpanding(boolean force, float velocity)524 private void finishExpanding(boolean force, float velocity) { 525 if (!mExpanding) return; 526 527 if (DEBUG) Log.d(TAG, "scale in finishing on view: " + mResizedView); 528 529 float currentHeight = mScaler.getHeight(); 530 float targetHeight = mSmallSize; 531 float h = mScaler.getHeight(); 532 final boolean wasClosed = (mOldHeight == mSmallSize); 533 if (wasClosed) { 534 targetHeight = (force || currentHeight > mSmallSize) ? mNaturalHeight : mSmallSize; 535 } else { 536 targetHeight = (force || currentHeight < mNaturalHeight) ? mSmallSize : mNaturalHeight; 537 } 538 if (mScaleAnimation.isRunning()) { 539 mScaleAnimation.cancel(); 540 } 541 mCallback.setUserExpandedChild(mResizedView, targetHeight == mNaturalHeight); 542 mCallback.expansionStateChanged(false); 543 if (targetHeight != currentHeight) { 544 mScaleAnimation.setFloatValues(targetHeight); 545 mScaleAnimation.setupStartValues(); 546 final View scaledView = mResizedView; 547 mScaleAnimation.addListener(new AnimatorListenerAdapter() { 548 @Override 549 public void onAnimationEnd(Animator animation) { 550 mCallback.setUserLockedChild(scaledView, false); 551 mScaleAnimation.removeListener(this); 552 } 553 }); 554 mFlingAnimationUtils.apply(mScaleAnimation, currentHeight, targetHeight, velocity); 555 mScaleAnimation.start(); 556 } else { 557 mCallback.setUserLockedChild(mResizedView, false); 558 } 559 560 mExpanding = false; 561 mExpansionStyle = NONE; 562 563 if (DEBUG) Log.d(TAG, "wasClosed is: " + wasClosed); 564 if (DEBUG) Log.d(TAG, "currentHeight is: " + currentHeight); 565 if (DEBUG) Log.d(TAG, "mSmallSize is: " + mSmallSize); 566 if (DEBUG) Log.d(TAG, "targetHeight is: " + targetHeight); 567 if (DEBUG) Log.d(TAG, "scale was finished on view: " + mResizedView); 568 } 569 clearView()570 private void clearView() { 571 mResizedView = null; 572 } 573 574 /** 575 * Use this to abort any pending expansions in progress. 576 */ cancel()577 public void cancel() { 578 finishExpanding(true, 0f /* velocity */); 579 clearView(); 580 581 // reset the gesture detector 582 mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener); 583 } 584 585 /** 586 * Change the expansion mode to only observe movements and don't perform any resizing. 587 * This is needed when the expanding is finished and the scroller kicks in, 588 * performing an overscroll motion. We only want to shrink it again when we are not 589 * overscrolled. 590 * 591 * @param onlyMovements Should only movements be observed? 592 */ onlyObserveMovements(boolean onlyMovements)593 public void onlyObserveMovements(boolean onlyMovements) { 594 mOnlyMovements = onlyMovements; 595 } 596 597 /** 598 * Triggers haptic feedback. 599 */ vibrate(long duration)600 private synchronized void vibrate(long duration) { 601 if (mVibrator == null) { 602 mVibrator = (android.os.Vibrator) 603 mContext.getSystemService(Context.VIBRATOR_SERVICE); 604 } 605 mVibrator.vibrate(duration, VIBRATION_ATTRIBUTES); 606 } 607 } 608 609