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