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.AnimatorSet; 23 import android.animation.ObjectAnimator; 24 import android.content.Context; 25 import android.os.Vibrator; 26 import android.util.Log; 27 import android.view.Gravity; 28 import android.view.MotionEvent; 29 import android.view.ScaleGestureDetector; 30 import android.view.ScaleGestureDetector.OnScaleGestureListener; 31 import android.view.View; 32 import android.view.View.OnClickListener; 33 import android.view.ViewConfiguration; 34 import android.view.ViewGroup; 35 36 public class ExpandHelper implements Gefingerpoken, OnClickListener { 37 public interface Callback { 38 View getChildAtRawPosition(float x, float y); 39 View getChildAtPosition(float x, float y); 40 boolean canChildBeExpanded(View v); 41 void setUserExpandedChild(View v, boolean userExpanded); 42 void setUserLockedChild(View v, boolean userLocked); 43 } 44 45 private static final String TAG = "ExpandHelper"; 46 protected static final boolean DEBUG = false; 47 protected static final boolean DEBUG_SCALE = false; 48 protected static final boolean DEBUG_GLOW = false; 49 private static final long EXPAND_DURATION = 250; 50 private static final long GLOW_DURATION = 150; 51 52 // Set to false to disable focus-based gestures (spread-finger vertical pull). 53 private static final boolean USE_DRAG = true; 54 // Set to false to disable scale-based gestures (both horizontal and vertical). 55 private static final boolean USE_SPAN = true; 56 // Both gestures types may be active at the same time. 57 // At least one gesture type should be active. 58 // A variant of the screwdriver gesture will emerge from either gesture type. 59 60 // amount of overstretch for maximum brightness expressed in U 61 // 2f: maximum brightness is stretching a 1U to 3U, or a 4U to 6U 62 private static final float STRETCH_INTERVAL = 2f; 63 64 // level of glow for a touch, without overstretch 65 // overstretch fills the range (GLOW_BASE, 1.0] 66 private static final float GLOW_BASE = 0.5f; 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 View mCurrView; 81 private View mCurrViewTopGlow; 82 private View mCurrViewBottomGlow; 83 private float mOldHeight; 84 private float mNaturalHeight; 85 private float mInitialTouchFocusY; 86 private float mInitialTouchY; 87 private float mInitialTouchSpan; 88 private float mLastFocusY; 89 private float mLastSpanY; 90 private int mTouchSlop; 91 private int mLastMotionY; 92 private float mPopLimit; 93 private int mPopDuration; 94 private float mPullGestureMinXSpan; 95 private Callback mCallback; 96 private ScaleGestureDetector mSGD; 97 private ViewScaler mScaler; 98 private ObjectAnimator mScaleAnimation; 99 private AnimatorSet mGlowAnimationSet; 100 private ObjectAnimator mGlowTopAnimation; 101 private ObjectAnimator mGlowBottomAnimation; 102 private Vibrator mVibrator; 103 104 private int mSmallSize; 105 private int mLargeSize; 106 private float mMaximumStretch; 107 108 private int mGravity; 109 110 private View mScrollView; 111 112 private OnScaleGestureListener mScaleGestureListener 113 = new ScaleGestureDetector.SimpleOnScaleGestureListener() { 114 @Override 115 public boolean onScaleBegin(ScaleGestureDetector detector) { 116 if (DEBUG_SCALE) Log.v(TAG, "onscalebegin()"); 117 float focusX = detector.getFocusX(); 118 float focusY = detector.getFocusY(); 119 120 final View underFocus = findView(focusX, focusY); 121 if (underFocus != null) { 122 startExpanding(underFocus, STRETCH); 123 } 124 return mExpanding; 125 } 126 127 @Override 128 public boolean onScale(ScaleGestureDetector detector) { 129 if (DEBUG_SCALE) Log.v(TAG, "onscale() on " + mCurrView); 130 return true; 131 } 132 133 @Override 134 public void onScaleEnd(ScaleGestureDetector detector) { 135 } 136 }; 137 138 private class ViewScaler { 139 View mView; 140 141 public ViewScaler() {} 142 public void setView(View v) { 143 mView = v; 144 } 145 public void setHeight(float h) { 146 if (DEBUG_SCALE) Log.v(TAG, "SetHeight: setting to " + h); 147 ViewGroup.LayoutParams lp = mView.getLayoutParams(); 148 lp.height = (int)h; 149 mView.setLayoutParams(lp); 150 mView.requestLayout(); 151 } 152 public float getHeight() { 153 int height = mView.getLayoutParams().height; 154 if (height < 0) { 155 height = mView.getMeasuredHeight(); 156 } 157 return height; 158 } 159 public int getNaturalHeight(int maximum) { 160 ViewGroup.LayoutParams lp = mView.getLayoutParams(); 161 if (DEBUG_SCALE) Log.v(TAG, "Inspecting a child of type: " + 162 mView.getClass().getName()); 163 int oldHeight = lp.height; 164 lp.height = ViewGroup.LayoutParams.WRAP_CONTENT; 165 mView.setLayoutParams(lp); 166 mView.measure( 167 View.MeasureSpec.makeMeasureSpec(mView.getMeasuredWidth(), 168 View.MeasureSpec.EXACTLY), 169 View.MeasureSpec.makeMeasureSpec(maximum, 170 View.MeasureSpec.AT_MOST)); 171 lp.height = oldHeight; 172 mView.setLayoutParams(lp); 173 return mView.getMeasuredHeight(); 174 } 175 } 176 177 /** 178 * Handle expansion gestures to expand and contract children of the callback. 179 * 180 * @param context application context 181 * @param callback the container that holds the items to be manipulated 182 * @param small the smallest allowable size for the manuipulated items. 183 * @param large the largest allowable size for the manuipulated items. 184 */ 185 public ExpandHelper(Context context, Callback callback, int small, int large) { 186 mSmallSize = small; 187 mMaximumStretch = mSmallSize * STRETCH_INTERVAL; 188 mLargeSize = large; 189 mContext = context; 190 mCallback = callback; 191 mScaler = new ViewScaler(); 192 mGravity = Gravity.TOP; 193 mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f); 194 mScaleAnimation.setDuration(EXPAND_DURATION); 195 mPopLimit = mContext.getResources().getDimension(R.dimen.blinds_pop_threshold); 196 mPopDuration = mContext.getResources().getInteger(R.integer.blinds_pop_duration_ms); 197 mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min); 198 199 AnimatorListenerAdapter glowVisibilityController = new AnimatorListenerAdapter() { 200 @Override 201 public void onAnimationStart(Animator animation) { 202 View target = (View) ((ObjectAnimator) animation).getTarget(); 203 if (target.getAlpha() <= 0.0f) { 204 target.setVisibility(View.VISIBLE); 205 } 206 } 207 208 @Override 209 public void onAnimationEnd(Animator animation) { 210 View target = (View) ((ObjectAnimator) animation).getTarget(); 211 if (target.getAlpha() <= 0.0f) { 212 target.setVisibility(View.INVISIBLE); 213 } 214 } 215 }; 216 217 mGlowTopAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f); 218 mGlowTopAnimation.addListener(glowVisibilityController); 219 mGlowBottomAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f); 220 mGlowBottomAnimation.addListener(glowVisibilityController); 221 mGlowAnimationSet = new AnimatorSet(); 222 mGlowAnimationSet.play(mGlowTopAnimation).with(mGlowBottomAnimation); 223 mGlowAnimationSet.setDuration(GLOW_DURATION); 224 225 final ViewConfiguration configuration = ViewConfiguration.get(mContext); 226 mTouchSlop = configuration.getScaledTouchSlop(); 227 228 mSGD = new ScaleGestureDetector(context, mScaleGestureListener); 229 } 230 231 private void updateExpansion() { 232 if (DEBUG_SCALE) Log.v(TAG, "updateExpansion()"); 233 // are we scaling or dragging? 234 float span = mSGD.getCurrentSpan() - mInitialTouchSpan; 235 span *= USE_SPAN ? 1f : 0f; 236 float drag = mSGD.getFocusY() - mInitialTouchFocusY; 237 drag *= USE_DRAG ? 1f : 0f; 238 drag *= mGravity == Gravity.BOTTOM ? -1f : 1f; 239 float pull = Math.abs(drag) + Math.abs(span) + 1f; 240 float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull; 241 float target = hand + mOldHeight; 242 float newHeight = clamp(target); 243 mScaler.setHeight(newHeight); 244 245 setGlow(calculateGlow(target, newHeight)); 246 mLastFocusY = mSGD.getFocusY(); 247 mLastSpanY = mSGD.getCurrentSpan(); 248 } 249 250 private float clamp(float target) { 251 float out = target; 252 out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out); 253 out = out > mNaturalHeight ? mNaturalHeight : out; 254 return out; 255 } 256 257 private View findView(float x, float y) { 258 View v = null; 259 if (mEventSource != null) { 260 int[] location = new int[2]; 261 mEventSource.getLocationOnScreen(location); 262 x += location[0]; 263 y += location[1]; 264 v = mCallback.getChildAtRawPosition(x, y); 265 } else { 266 v = mCallback.getChildAtPosition(x, y); 267 } 268 return v; 269 } 270 271 private boolean isInside(View v, float x, float y) { 272 if (DEBUG) Log.d(TAG, "isinside (" + x + ", " + y + ")"); 273 274 if (v == null) { 275 if (DEBUG) Log.d(TAG, "isinside null subject"); 276 return false; 277 } 278 if (mEventSource != null) { 279 int[] location = new int[2]; 280 mEventSource.getLocationOnScreen(location); 281 x += location[0]; 282 y += location[1]; 283 if (DEBUG) Log.d(TAG, " to global (" + x + ", " + y + ")"); 284 } 285 int[] location = new int[2]; 286 v.getLocationOnScreen(location); 287 x -= location[0]; 288 y -= location[1]; 289 if (DEBUG) Log.d(TAG, " to local (" + x + ", " + y + ")"); 290 if (DEBUG) Log.d(TAG, " inside (" + v.getWidth() + ", " + v.getHeight() + ")"); 291 boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight()); 292 return inside; 293 } 294 295 public void setEventSource(View eventSource) { 296 mEventSource = eventSource; 297 } 298 299 public void setGravity(int gravity) { 300 mGravity = gravity; 301 } 302 303 public void setScrollView(View scrollView) { 304 mScrollView = scrollView; 305 } 306 307 private float calculateGlow(float target, float actual) { 308 // glow if overscale 309 if (DEBUG_GLOW) Log.d(TAG, "target: " + target + " actual: " + actual); 310 float stretch = Math.abs((target - actual) / mMaximumStretch); 311 float strength = 1f / (1f + (float) Math.pow(Math.E, -1 * ((8f * stretch) - 5f))); 312 if (DEBUG_GLOW) Log.d(TAG, "stretch: " + stretch + " strength: " + strength); 313 return (GLOW_BASE + strength * (1f - GLOW_BASE)); 314 } 315 316 public void setGlow(float glow) { 317 if (!mGlowAnimationSet.isRunning() || glow == 0f) { 318 if (mGlowAnimationSet.isRunning()) { 319 mGlowAnimationSet.end(); 320 } 321 if (mCurrViewTopGlow != null && mCurrViewBottomGlow != null) { 322 if (glow == 0f || mCurrViewTopGlow.getAlpha() == 0f) { 323 // animate glow in and out 324 mGlowTopAnimation.setTarget(mCurrViewTopGlow); 325 mGlowBottomAnimation.setTarget(mCurrViewBottomGlow); 326 mGlowTopAnimation.setFloatValues(glow); 327 mGlowBottomAnimation.setFloatValues(glow); 328 mGlowAnimationSet.setupStartValues(); 329 mGlowAnimationSet.start(); 330 } else { 331 // set it explicitly in reponse to touches. 332 mCurrViewTopGlow.setAlpha(glow); 333 mCurrViewBottomGlow.setAlpha(glow); 334 handleGlowVisibility(); 335 } 336 } 337 } 338 } 339 340 private void handleGlowVisibility() { 341 mCurrViewTopGlow.setVisibility(mCurrViewTopGlow.getAlpha() <= 0.0f ? 342 View.INVISIBLE : View.VISIBLE); 343 mCurrViewBottomGlow.setVisibility(mCurrViewBottomGlow.getAlpha() <= 0.0f ? 344 View.INVISIBLE : View.VISIBLE); 345 } 346 347 @Override 348 public boolean onInterceptTouchEvent(MotionEvent ev) { 349 final int action = ev.getAction(); 350 if (DEBUG_SCALE) Log.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) + 351 " expanding=" + mExpanding + 352 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 353 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 354 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 355 // check for a spread-finger vertical pull gesture 356 mSGD.onTouchEvent(ev); 357 final int x = (int) mSGD.getFocusX(); 358 final int y = (int) mSGD.getFocusY(); 359 360 mInitialTouchFocusY = y; 361 mInitialTouchSpan = mSGD.getCurrentSpan(); 362 mLastFocusY = mInitialTouchFocusY; 363 mLastSpanY = mInitialTouchSpan; 364 if (DEBUG_SCALE) Log.d(TAG, "set initial span: " + mInitialTouchSpan); 365 366 if (mExpanding) { 367 return true; 368 } else { 369 if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) { 370 // we've begun Venetian blinds style expansion 371 return true; 372 } 373 final float xspan = mSGD.getCurrentSpanX(); 374 if ((action == MotionEvent.ACTION_MOVE && 375 xspan > mPullGestureMinXSpan && 376 xspan > mSGD.getCurrentSpanY())) { 377 // detect a vertical pulling gesture with fingers somewhat separated 378 if (DEBUG_SCALE) Log.v(TAG, "got pull gesture (xspan=" + xspan + "px)"); 379 380 final View underFocus = findView(x, y); 381 if (underFocus != null) { 382 startExpanding(underFocus, PULL); 383 } 384 return true; 385 } 386 if (mScrollView != null && mScrollView.getScrollY() > 0) { 387 return false; 388 } 389 // Now look for other gestures 390 switch (action & MotionEvent.ACTION_MASK) { 391 case MotionEvent.ACTION_MOVE: { 392 if (mWatchingForPull) { 393 final int yDiff = y - mLastMotionY; 394 if (yDiff > mTouchSlop) { 395 if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)"); 396 mLastMotionY = y; 397 final View underFocus = findView(x, y); 398 if (underFocus != null) { 399 startExpanding(underFocus, BLINDS); 400 mInitialTouchY = mLastMotionY; 401 mHasPopped = false; 402 } 403 } 404 } 405 break; 406 } 407 408 case MotionEvent.ACTION_DOWN: 409 mWatchingForPull = isInside(mScrollView, x, y); 410 mLastMotionY = y; 411 break; 412 413 case MotionEvent.ACTION_CANCEL: 414 case MotionEvent.ACTION_UP: 415 if (DEBUG) Log.d(TAG, "up/cancel"); 416 finishExpanding(false); 417 clearView(); 418 break; 419 } 420 return mExpanding; 421 } 422 } 423 424 @Override 425 public boolean onTouchEvent(MotionEvent ev) { 426 final int action = ev.getActionMasked(); 427 if (DEBUG_SCALE) Log.d(TAG, "touch: act=" + MotionEvent.actionToString(action) + 428 " expanding=" + mExpanding + 429 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 430 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 431 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 432 433 mSGD.onTouchEvent(ev); 434 435 switch (action) { 436 case MotionEvent.ACTION_MOVE: { 437 if (0 != (mExpansionStyle & BLINDS)) { 438 final float rawHeight = ev.getY() - mInitialTouchY + mOldHeight; 439 final float newHeight = clamp(rawHeight); 440 final boolean wasClosed = (mOldHeight == mSmallSize); 441 boolean isFinished = false; 442 if (rawHeight > mNaturalHeight) { 443 isFinished = true; 444 } 445 if (rawHeight < mSmallSize) { 446 isFinished = true; 447 } 448 449 final float pull = Math.abs(ev.getY() - mInitialTouchY); 450 if (mHasPopped || pull > mPopLimit) { 451 if (!mHasPopped) { 452 vibrate(mPopDuration); 453 mHasPopped = true; 454 } 455 } 456 457 if (mHasPopped) { 458 mScaler.setHeight(newHeight); 459 setGlow(GLOW_BASE); 460 } else { 461 setGlow(calculateGlow(4f * pull, 0f)); 462 } 463 464 final int x = (int) mSGD.getFocusX(); 465 final int y = (int) mSGD.getFocusY(); 466 View underFocus = findView(x, y); 467 if (isFinished && underFocus != null && underFocus != mCurrView) { 468 finishExpanding(false); // @@@ needed? 469 startExpanding(underFocus, BLINDS); 470 mInitialTouchY = y; 471 mHasPopped = false; 472 } 473 return true; 474 } 475 476 if (mExpanding) { 477 updateExpansion(); 478 return true; 479 } 480 481 break; 482 } 483 484 case MotionEvent.ACTION_POINTER_UP: 485 case MotionEvent.ACTION_POINTER_DOWN: 486 if (DEBUG) Log.d(TAG, "pointer change"); 487 mInitialTouchY += mSGD.getFocusY() - mLastFocusY; 488 mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY; 489 break; 490 491 case MotionEvent.ACTION_UP: 492 case MotionEvent.ACTION_CANCEL: 493 if (DEBUG) Log.d(TAG, "up/cancel"); 494 finishExpanding(false); 495 clearView(); 496 break; 497 } 498 return true; 499 } 500 501 private void startExpanding(View v, int expandType) { 502 mExpansionStyle = expandType; 503 if (mExpanding && v == mCurrView) { 504 return; 505 } 506 mExpanding = true; 507 if (DEBUG) Log.d(TAG, "scale type " + expandType + " beginning on view: " + v); 508 mCallback.setUserLockedChild(v, true); 509 setView(v); 510 setGlow(GLOW_BASE); 511 mScaler.setView(v); 512 mOldHeight = mScaler.getHeight(); 513 if (mCallback.canChildBeExpanded(v)) { 514 if (DEBUG) Log.d(TAG, "working on an expandable child"); 515 mNaturalHeight = mScaler.getNaturalHeight(mLargeSize); 516 } else { 517 if (DEBUG) Log.d(TAG, "working on a non-expandable child"); 518 mNaturalHeight = mOldHeight; 519 } 520 if (DEBUG) Log.d(TAG, "got mOldHeight: " + mOldHeight + 521 " mNaturalHeight: " + mNaturalHeight); 522 v.getParent().requestDisallowInterceptTouchEvent(true); 523 } 524 525 private void finishExpanding(boolean force) { 526 if (!mExpanding) return; 527 528 if (DEBUG) Log.d(TAG, "scale in finishing on view: " + mCurrView); 529 530 float currentHeight = mScaler.getHeight(); 531 float targetHeight = mSmallSize; 532 float h = mScaler.getHeight(); 533 final boolean wasClosed = (mOldHeight == mSmallSize); 534 if (wasClosed) { 535 targetHeight = (force || currentHeight > mSmallSize) ? mNaturalHeight : mSmallSize; 536 } else { 537 targetHeight = (force || currentHeight < mNaturalHeight) ? mSmallSize : mNaturalHeight; 538 } 539 if (mScaleAnimation.isRunning()) { 540 mScaleAnimation.cancel(); 541 } 542 setGlow(0f); 543 mCallback.setUserExpandedChild(mCurrView, h == mNaturalHeight); 544 if (targetHeight != currentHeight) { 545 mScaleAnimation.setFloatValues(targetHeight); 546 mScaleAnimation.setupStartValues(); 547 mScaleAnimation.start(); 548 } 549 mCallback.setUserLockedChild(mCurrView, false); 550 551 mExpanding = false; 552 mExpansionStyle = NONE; 553 554 if (DEBUG) Log.d(TAG, "wasClosed is: " + wasClosed); 555 if (DEBUG) Log.d(TAG, "currentHeight is: " + currentHeight); 556 if (DEBUG) Log.d(TAG, "mSmallSize is: " + mSmallSize); 557 if (DEBUG) Log.d(TAG, "targetHeight is: " + targetHeight); 558 if (DEBUG) Log.d(TAG, "scale was finished on view: " + mCurrView); 559 } 560 561 private void clearView() { 562 mCurrView = null; 563 mCurrViewTopGlow = null; 564 mCurrViewBottomGlow = null; 565 } 566 567 private void setView(View v) { 568 mCurrView = v; 569 if (v instanceof ViewGroup) { 570 ViewGroup g = (ViewGroup) v; 571 mCurrViewTopGlow = g.findViewById(R.id.top_glow); 572 mCurrViewBottomGlow = g.findViewById(R.id.bottom_glow); 573 if (DEBUG) { 574 String debugLog = "Looking for glows: " + 575 (mCurrViewTopGlow != null ? "found top " : "didn't find top") + 576 (mCurrViewBottomGlow != null ? "found bottom " : "didn't find bottom"); 577 Log.v(TAG, debugLog); 578 } 579 } 580 } 581 582 @Override 583 public void onClick(View v) { 584 startExpanding(v, STRETCH); 585 finishExpanding(true); 586 clearView(); 587 } 588 589 /** 590 * Use this to abort any pending expansions in progress. 591 */ 592 public void cancel() { 593 finishExpanding(true); 594 clearView(); 595 596 // reset the gesture detector 597 mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener); 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); 609 } 610 } 611 612