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