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.Slog; 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.ViewConfiguration; 33 import android.view.ViewGroup; 34 import android.view.View.OnClickListener; 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 boolean setUserExpandedChild(View v, boolean userExpanded); 42 boolean 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) Slog.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) Slog.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) Slog.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) Slog.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 * @param scoller if non-null also manipulate the scroll position to obey the gravity. 185 */ 186 public ExpandHelper(Context context, Callback callback, int small, int large) { 187 mSmallSize = small; 188 mMaximumStretch = mSmallSize * STRETCH_INTERVAL; 189 mLargeSize = large; 190 mContext = context; 191 mCallback = callback; 192 mScaler = new ViewScaler(); 193 mGravity = Gravity.TOP; 194 mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f); 195 mScaleAnimation.setDuration(EXPAND_DURATION); 196 mPopLimit = mContext.getResources().getDimension(R.dimen.blinds_pop_threshold); 197 mPopDuration = mContext.getResources().getInteger(R.integer.blinds_pop_duration_ms); 198 mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min); 199 200 AnimatorListenerAdapter glowVisibilityController = new AnimatorListenerAdapter() { 201 @Override 202 public void onAnimationStart(Animator animation) { 203 View target = (View) ((ObjectAnimator) animation).getTarget(); 204 if (target.getAlpha() <= 0.0f) { 205 target.setVisibility(View.VISIBLE); 206 } 207 } 208 209 @Override 210 public void onAnimationEnd(Animator animation) { 211 View target = (View) ((ObjectAnimator) animation).getTarget(); 212 if (target.getAlpha() <= 0.0f) { 213 target.setVisibility(View.INVISIBLE); 214 } 215 } 216 }; 217 218 mGlowTopAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f); 219 mGlowTopAnimation.addListener(glowVisibilityController); 220 mGlowBottomAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f); 221 mGlowBottomAnimation.addListener(glowVisibilityController); 222 mGlowAnimationSet = new AnimatorSet(); 223 mGlowAnimationSet.play(mGlowTopAnimation).with(mGlowBottomAnimation); 224 mGlowAnimationSet.setDuration(GLOW_DURATION); 225 226 final ViewConfiguration configuration = ViewConfiguration.get(mContext); 227 mTouchSlop = configuration.getScaledTouchSlop(); 228 229 mSGD = new ScaleGestureDetector(context, mScaleGestureListener); 230 } 231 232 private void updateExpansion() { 233 if (DEBUG_SCALE) Slog.v(TAG, "updateExpansion()"); 234 // are we scaling or dragging? 235 float span = mSGD.getCurrentSpan() - mInitialTouchSpan; 236 span *= USE_SPAN ? 1f : 0f; 237 float drag = mSGD.getFocusY() - mInitialTouchFocusY; 238 drag *= USE_DRAG ? 1f : 0f; 239 drag *= mGravity == Gravity.BOTTOM ? -1f : 1f; 240 float pull = Math.abs(drag) + Math.abs(span) + 1f; 241 float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull; 242 float target = hand + mOldHeight; 243 float newHeight = clamp(target); 244 mScaler.setHeight(newHeight); 245 246 setGlow(calculateGlow(target, newHeight)); 247 mLastFocusY = mSGD.getFocusY(); 248 mLastSpanY = mSGD.getCurrentSpan(); 249 } 250 251 private float clamp(float target) { 252 float out = target; 253 out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out); 254 out = out > mNaturalHeight ? mNaturalHeight : out; 255 return out; 256 } 257 258 private View findView(float x, float y) { 259 View v = null; 260 if (mEventSource != null) { 261 int[] location = new int[2]; 262 mEventSource.getLocationOnScreen(location); 263 x += location[0]; 264 y += location[1]; 265 v = mCallback.getChildAtRawPosition(x, y); 266 } else { 267 v = mCallback.getChildAtPosition(x, y); 268 } 269 return v; 270 } 271 272 private boolean isInside(View v, float x, float y) { 273 if (DEBUG) Slog.d(TAG, "isinside (" + x + ", " + y + ")"); 274 275 if (v == null) { 276 if (DEBUG) Slog.d(TAG, "isinside null subject"); 277 return false; 278 } 279 if (mEventSource != null) { 280 int[] location = new int[2]; 281 mEventSource.getLocationOnScreen(location); 282 x += location[0]; 283 y += location[1]; 284 if (DEBUG) Slog.d(TAG, " to global (" + x + ", " + y + ")"); 285 } 286 int[] location = new int[2]; 287 v.getLocationOnScreen(location); 288 x -= location[0]; 289 y -= location[1]; 290 if (DEBUG) Slog.d(TAG, " to local (" + x + ", " + y + ")"); 291 if (DEBUG) Slog.d(TAG, " inside (" + v.getWidth() + ", " + v.getHeight() + ")"); 292 boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight()); 293 return inside; 294 } 295 296 public void setEventSource(View eventSource) { 297 mEventSource = eventSource; 298 } 299 300 public void setGravity(int gravity) { 301 mGravity = gravity; 302 } 303 304 public void setScrollView(View scrollView) { 305 mScrollView = scrollView; 306 } 307 308 private float calculateGlow(float target, float actual) { 309 // glow if overscale 310 if (DEBUG_GLOW) Slog.d(TAG, "target: " + target + " actual: " + actual); 311 float stretch = Math.abs((target - actual) / mMaximumStretch); 312 float strength = 1f / (1f + (float) Math.pow(Math.E, -1 * ((8f * stretch) - 5f))); 313 if (DEBUG_GLOW) Slog.d(TAG, "stretch: " + stretch + " strength: " + strength); 314 return (GLOW_BASE + strength * (1f - GLOW_BASE)); 315 } 316 317 public void setGlow(float glow) { 318 if (!mGlowAnimationSet.isRunning() || glow == 0f) { 319 if (mGlowAnimationSet.isRunning()) { 320 mGlowAnimationSet.end(); 321 } 322 if (mCurrViewTopGlow != null && mCurrViewBottomGlow != null) { 323 if (glow == 0f || mCurrViewTopGlow.getAlpha() == 0f) { 324 // animate glow in and out 325 mGlowTopAnimation.setTarget(mCurrViewTopGlow); 326 mGlowBottomAnimation.setTarget(mCurrViewBottomGlow); 327 mGlowTopAnimation.setFloatValues(glow); 328 mGlowBottomAnimation.setFloatValues(glow); 329 mGlowAnimationSet.setupStartValues(); 330 mGlowAnimationSet.start(); 331 } else { 332 // set it explicitly in reponse to touches. 333 mCurrViewTopGlow.setAlpha(glow); 334 mCurrViewBottomGlow.setAlpha(glow); 335 handleGlowVisibility(); 336 } 337 } 338 } 339 } 340 341 private void handleGlowVisibility() { 342 mCurrViewTopGlow.setVisibility(mCurrViewTopGlow.getAlpha() <= 0.0f ? 343 View.INVISIBLE : View.VISIBLE); 344 mCurrViewBottomGlow.setVisibility(mCurrViewBottomGlow.getAlpha() <= 0.0f ? 345 View.INVISIBLE : View.VISIBLE); 346 } 347 348 @Override 349 public boolean onInterceptTouchEvent(MotionEvent ev) { 350 final int action = ev.getAction(); 351 if (DEBUG_SCALE) Slog.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) + 352 " expanding=" + mExpanding + 353 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 354 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 355 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 356 // check for a spread-finger vertical pull gesture 357 mSGD.onTouchEvent(ev); 358 final int x = (int) mSGD.getFocusX(); 359 final int y = (int) mSGD.getFocusY(); 360 361 mInitialTouchFocusY = y; 362 mInitialTouchSpan = mSGD.getCurrentSpan(); 363 mLastFocusY = mInitialTouchFocusY; 364 mLastSpanY = mInitialTouchSpan; 365 if (DEBUG_SCALE) Slog.d(TAG, "set initial span: " + mInitialTouchSpan); 366 367 if (mExpanding) { 368 return true; 369 } else { 370 if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) { 371 // we've begun Venetian blinds style expansion 372 return true; 373 } 374 final float xspan = mSGD.getCurrentSpanX(); 375 if ((action == MotionEvent.ACTION_MOVE && 376 xspan > mPullGestureMinXSpan && 377 xspan > mSGD.getCurrentSpanY())) { 378 // detect a vertical pulling gesture with fingers somewhat separated 379 if (DEBUG_SCALE) Slog.v(TAG, "got pull gesture (xspan=" + xspan + "px)"); 380 381 final View underFocus = findView(x, y); 382 if (underFocus != null) { 383 startExpanding(underFocus, PULL); 384 } 385 return true; 386 } 387 if (mScrollView != null && mScrollView.getScrollY() > 0) { 388 return false; 389 } 390 // Now look for other gestures 391 switch (action & MotionEvent.ACTION_MASK) { 392 case MotionEvent.ACTION_MOVE: { 393 if (mWatchingForPull) { 394 final int yDiff = y - mLastMotionY; 395 if (yDiff > mTouchSlop) { 396 if (DEBUG) Slog.v(TAG, "got venetian gesture (dy=" + yDiff + "px)"); 397 mLastMotionY = y; 398 final View underFocus = findView(x, y); 399 if (underFocus != null) { 400 startExpanding(underFocus, BLINDS); 401 mInitialTouchY = mLastMotionY; 402 mHasPopped = false; 403 } 404 } 405 } 406 break; 407 } 408 409 case MotionEvent.ACTION_DOWN: 410 mWatchingForPull = isInside(mScrollView, x, y); 411 mLastMotionY = y; 412 break; 413 414 case MotionEvent.ACTION_CANCEL: 415 case MotionEvent.ACTION_UP: 416 if (DEBUG) Slog.d(TAG, "up/cancel"); 417 finishExpanding(false); 418 clearView(); 419 break; 420 } 421 return mExpanding; 422 } 423 } 424 425 @Override 426 public boolean onTouchEvent(MotionEvent ev) { 427 final int action = ev.getActionMasked(); 428 if (DEBUG_SCALE) Slog.d(TAG, "touch: act=" + MotionEvent.actionToString(action) + 429 " expanding=" + mExpanding + 430 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + 431 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + 432 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); 433 434 mSGD.onTouchEvent(ev); 435 436 switch (action) { 437 case MotionEvent.ACTION_MOVE: { 438 if (0 != (mExpansionStyle & BLINDS)) { 439 final float rawHeight = ev.getY() - mInitialTouchY + mOldHeight; 440 final float newHeight = clamp(rawHeight); 441 final boolean wasClosed = (mOldHeight == mSmallSize); 442 boolean isFinished = false; 443 if (rawHeight > mNaturalHeight) { 444 isFinished = true; 445 } 446 if (rawHeight < mSmallSize) { 447 isFinished = true; 448 } 449 450 final float pull = Math.abs(ev.getY() - mInitialTouchY); 451 if (mHasPopped || pull > mPopLimit) { 452 if (!mHasPopped) { 453 vibrate(mPopDuration); 454 mHasPopped = true; 455 } 456 } 457 458 if (mHasPopped) { 459 mScaler.setHeight(newHeight); 460 setGlow(GLOW_BASE); 461 } else { 462 setGlow(calculateGlow(4f * pull, 0f)); 463 } 464 465 final int x = (int) mSGD.getFocusX(); 466 final int y = (int) mSGD.getFocusY(); 467 View underFocus = findView(x, y); 468 if (isFinished && underFocus != null && underFocus != mCurrView) { 469 finishExpanding(false); // @@@ needed? 470 startExpanding(underFocus, BLINDS); 471 mInitialTouchY = y; 472 mHasPopped = false; 473 } 474 return true; 475 } 476 477 if (mExpanding) { 478 updateExpansion(); 479 return true; 480 } 481 482 break; 483 } 484 485 case MotionEvent.ACTION_POINTER_UP: 486 case MotionEvent.ACTION_POINTER_DOWN: 487 if (DEBUG) Slog.d(TAG, "pointer change"); 488 mInitialTouchY += mSGD.getFocusY() - mLastFocusY; 489 mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY; 490 break; 491 492 case MotionEvent.ACTION_UP: 493 case MotionEvent.ACTION_CANCEL: 494 if (DEBUG) Slog.d(TAG, "up/cancel"); 495 finishExpanding(false); 496 clearView(); 497 break; 498 } 499 return true; 500 } 501 502 private void startExpanding(View v, int expandType) { 503 mExpansionStyle = expandType; 504 if (mExpanding && v == mCurrView) { 505 return; 506 } 507 mExpanding = true; 508 if (DEBUG) Slog.d(TAG, "scale type " + expandType + " beginning on view: " + v); 509 mCallback.setUserLockedChild(v, true); 510 setView(v); 511 setGlow(GLOW_BASE); 512 mScaler.setView(v); 513 mOldHeight = mScaler.getHeight(); 514 if (mCallback.canChildBeExpanded(v)) { 515 if (DEBUG) Slog.d(TAG, "working on an expandable child"); 516 mNaturalHeight = mScaler.getNaturalHeight(mLargeSize); 517 } else { 518 if (DEBUG) Slog.d(TAG, "working on a non-expandable child"); 519 mNaturalHeight = mOldHeight; 520 } 521 if (DEBUG) Slog.d(TAG, "got mOldHeight: " + mOldHeight + 522 " mNaturalHeight: " + mNaturalHeight); 523 v.getParent().requestDisallowInterceptTouchEvent(true); 524 } 525 526 private void finishExpanding(boolean force) { 527 if (!mExpanding) return; 528 529 if (DEBUG) Slog.d(TAG, "scale in finishing on view: " + mCurrView); 530 531 float currentHeight = mScaler.getHeight(); 532 float targetHeight = mSmallSize; 533 float h = mScaler.getHeight(); 534 final boolean wasClosed = (mOldHeight == mSmallSize); 535 if (wasClosed) { 536 targetHeight = (force || currentHeight > mSmallSize) ? mNaturalHeight : mSmallSize; 537 } else { 538 targetHeight = (force || currentHeight < mNaturalHeight) ? mSmallSize : mNaturalHeight; 539 } 540 if (mScaleAnimation.isRunning()) { 541 mScaleAnimation.cancel(); 542 } 543 setGlow(0f); 544 mCallback.setUserExpandedChild(mCurrView, h == mNaturalHeight); 545 if (targetHeight != currentHeight) { 546 mScaleAnimation.setFloatValues(targetHeight); 547 mScaleAnimation.setupStartValues(); 548 mScaleAnimation.start(); 549 } 550 mCallback.setUserLockedChild(mCurrView, false); 551 552 mExpanding = false; 553 mExpansionStyle = NONE; 554 555 if (DEBUG) Slog.d(TAG, "wasClosed is: " + wasClosed); 556 if (DEBUG) Slog.d(TAG, "currentHeight is: " + currentHeight); 557 if (DEBUG) Slog.d(TAG, "mSmallSize is: " + mSmallSize); 558 if (DEBUG) Slog.d(TAG, "targetHeight is: " + targetHeight); 559 if (DEBUG) Slog.d(TAG, "scale was finished on view: " + mCurrView); 560 } 561 562 private void clearView() { 563 mCurrView = null; 564 mCurrViewTopGlow = null; 565 mCurrViewBottomGlow = null; 566 } 567 568 private void setView(View v) { 569 mCurrView = v; 570 if (v instanceof ViewGroup) { 571 ViewGroup g = (ViewGroup) v; 572 mCurrViewTopGlow = g.findViewById(R.id.top_glow); 573 mCurrViewBottomGlow = g.findViewById(R.id.bottom_glow); 574 if (DEBUG) { 575 String debugLog = "Looking for glows: " + 576 (mCurrViewTopGlow != null ? "found top " : "didn't find top") + 577 (mCurrViewBottomGlow != null ? "found bottom " : "didn't find bottom"); 578 Slog.v(TAG, debugLog); 579 } 580 } 581 } 582 583 @Override 584 public void onClick(View v) { 585 startExpanding(v, STRETCH); 586 finishExpanding(true); 587 clearView(); 588 } 589 590 /** 591 * Use this to abort any pending expansions in progress. 592 */ 593 public void cancel() { 594 finishExpanding(true); 595 clearView(); 596 597 // reset the gesture detector 598 mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener); 599 } 600 601 /** 602 * Triggers haptic feedback. 603 */ 604 private synchronized void vibrate(long duration) { 605 if (mVibrator == null) { 606 mVibrator = (android.os.Vibrator) 607 mContext.getSystemService(Context.VIBRATOR_SERVICE); 608 } 609 mVibrator.vibrate(duration); 610 } 611 } 612 613