1 package com.android.launcher3.allapps; 2 3 import android.animation.Animator; 4 import android.animation.AnimatorInflater; 5 import android.animation.AnimatorListenerAdapter; 6 import android.animation.AnimatorSet; 7 import android.animation.ArgbEvaluator; 8 import android.animation.ObjectAnimator; 9 import android.graphics.Color; 10 import android.support.v4.content.ContextCompat; 11 import android.support.v4.graphics.ColorUtils; 12 import android.support.v4.view.animation.FastOutSlowInInterpolator; 13 import android.util.Log; 14 import android.view.MotionEvent; 15 import android.view.View; 16 import android.view.animation.AccelerateInterpolator; 17 import android.view.animation.DecelerateInterpolator; 18 import android.view.animation.Interpolator; 19 20 import com.android.launcher3.DeviceProfile; 21 import com.android.launcher3.Hotseat; 22 import com.android.launcher3.Launcher; 23 import com.android.launcher3.LauncherAnimUtils; 24 import com.android.launcher3.R; 25 import com.android.launcher3.Utilities; 26 import com.android.launcher3.Workspace; 27 import com.android.launcher3.userevent.nano.LauncherLogProto; 28 import com.android.launcher3.util.TouchController; 29 30 /** 31 * Handles AllApps view transition. 32 * 1) Slides all apps view using direct manipulation 33 * 2) When finger is released, animate to either top or bottom accordingly. 34 * <p/> 35 * Algorithm: 36 * If release velocity > THRES1, snap according to the direction of movement. 37 * If release velocity < THRES1, snap according to either top or bottom depending on whether it's 38 * closer to top or closer to the page indicator. 39 */ 40 public class AllAppsTransitionController implements TouchController, VerticalPullDetector.Listener, 41 View.OnLayoutChangeListener { 42 43 private static final String TAG = "AllAppsTrans"; 44 private static final boolean DBG = false; 45 46 private final Interpolator mAccelInterpolator = new AccelerateInterpolator(2f); 47 private final Interpolator mDecelInterpolator = new DecelerateInterpolator(3f); 48 private final Interpolator mFastOutSlowInInterpolator = new FastOutSlowInInterpolator(); 49 private final ScrollInterpolator mScrollInterpolator = new ScrollInterpolator(); 50 51 private static final float ANIMATION_DURATION = 1200; 52 private static final float PARALLAX_COEFFICIENT = .125f; 53 private static final float FAST_FLING_PX_MS = 10; 54 private static final int SINGLE_FRAME_MS = 16; 55 56 private AllAppsContainerView mAppsView; 57 private int mAllAppsBackgroundColor; 58 private Workspace mWorkspace; 59 private Hotseat mHotseat; 60 private int mHotseatBackgroundColor; 61 62 private AllAppsCaretController mCaretController; 63 64 private float mStatusBarHeight; 65 66 private final Launcher mLauncher; 67 private final VerticalPullDetector mDetector; 68 private final ArgbEvaluator mEvaluator; 69 70 // Animation in this class is controlled by a single variable {@link mProgress}. 71 // Visually, it represents top y coordinate of the all apps container if multiplied with 72 // {@link mShiftRange}. 73 74 // When {@link mProgress} is 0, all apps container is pulled up. 75 // When {@link mProgress} is 1, all apps container is pulled down. 76 private float mShiftStart; // [0, mShiftRange] 77 private float mShiftRange; // changes depending on the orientation 78 private float mProgress; // [0, 1], mShiftRange * mProgress = shiftCurrent 79 80 // Velocity of the container. Unit is in px/ms. 81 private float mContainerVelocity; 82 83 private static final float DEFAULT_SHIFT_RANGE = 10; 84 85 private static final float RECATCH_REJECTION_FRACTION = .0875f; 86 87 private int mBezelSwipeUpHeight; 88 private long mAnimationDuration; 89 90 private AnimatorSet mCurrentAnimation; 91 private boolean mNoIntercept; 92 93 // Used in discovery bounce animation to provide the transition without workspace changing. 94 private boolean mIsTranslateWithoutWorkspace = false; 95 private AnimatorSet mDiscoBounceAnimation; 96 97 public AllAppsTransitionController(Launcher l) { 98 mLauncher = l; 99 mDetector = new VerticalPullDetector(l); 100 mDetector.setListener(this); 101 mShiftRange = DEFAULT_SHIFT_RANGE; 102 mProgress = 1f; 103 mBezelSwipeUpHeight = l.getResources().getDimensionPixelSize( 104 R.dimen.all_apps_bezel_swipe_height); 105 106 mEvaluator = new ArgbEvaluator(); 107 mAllAppsBackgroundColor = ContextCompat.getColor(l, R.color.all_apps_container_color); 108 } 109 110 @Override 111 public boolean onInterceptTouchEvent(MotionEvent ev) { 112 if (ev.getAction() == MotionEvent.ACTION_DOWN) { 113 mNoIntercept = false; 114 if (!mLauncher.isAllAppsVisible() && mLauncher.getWorkspace().workspaceInModalState()) { 115 mNoIntercept = true; 116 } else if (mLauncher.isAllAppsVisible() && 117 !mAppsView.shouldContainerScroll(ev)) { 118 mNoIntercept = true; 119 } else if (!mLauncher.isAllAppsVisible() && !shouldPossiblyIntercept(ev)) { 120 mNoIntercept = true; 121 } else { 122 // Now figure out which direction scroll events the controller will start 123 // calling the callbacks. 124 int directionsToDetectScroll = 0; 125 boolean ignoreSlopWhenSettling = false; 126 127 if (mDetector.isIdleState()) { 128 if (mLauncher.isAllAppsVisible()) { 129 directionsToDetectScroll |= VerticalPullDetector.DIRECTION_DOWN; 130 } else { 131 directionsToDetectScroll |= VerticalPullDetector.DIRECTION_UP; 132 } 133 } else { 134 if (isInDisallowRecatchBottomZone()) { 135 directionsToDetectScroll |= VerticalPullDetector.DIRECTION_UP; 136 } else if (isInDisallowRecatchTopZone()) { 137 directionsToDetectScroll |= VerticalPullDetector.DIRECTION_DOWN; 138 } else { 139 directionsToDetectScroll |= VerticalPullDetector.DIRECTION_BOTH; 140 ignoreSlopWhenSettling = true; 141 } 142 } 143 mDetector.setDetectableScrollConditions(directionsToDetectScroll, 144 ignoreSlopWhenSettling); 145 } 146 } 147 if (mNoIntercept) { 148 return false; 149 } 150 mDetector.onTouchEvent(ev); 151 if (mDetector.isSettlingState() && (isInDisallowRecatchBottomZone() || isInDisallowRecatchTopZone())) { 152 return false; 153 } 154 return mDetector.isDraggingOrSettling(); 155 } 156 157 private boolean shouldPossiblyIntercept(MotionEvent ev) { 158 DeviceProfile grid = mLauncher.getDeviceProfile(); 159 if (mDetector.isIdleState()) { 160 if (grid.isVerticalBarLayout()) { 161 if (ev.getY() > mLauncher.getDeviceProfile().heightPx - mBezelSwipeUpHeight) { 162 return true; 163 } 164 } else { 165 if (mLauncher.getDragLayer().isEventOverHotseat(ev) || 166 mLauncher.getDragLayer().isEventOverPageIndicator(ev)) { 167 return true; 168 } 169 } 170 return false; 171 } else { 172 return true; 173 } 174 } 175 176 @Override 177 public boolean onTouchEvent(MotionEvent ev) { 178 return mDetector.onTouchEvent(ev); 179 } 180 181 private boolean isInDisallowRecatchTopZone() { 182 return mProgress < RECATCH_REJECTION_FRACTION; 183 } 184 185 private boolean isInDisallowRecatchBottomZone() { 186 return mProgress > 1 - RECATCH_REJECTION_FRACTION; 187 } 188 189 @Override 190 public void onDragStart(boolean start) { 191 mCaretController.onDragStart(); 192 cancelAnimation(); 193 mCurrentAnimation = LauncherAnimUtils.createAnimatorSet(); 194 mShiftStart = mAppsView.getTranslationY(); 195 preparePull(start); 196 } 197 198 @Override 199 public boolean onDrag(float displacement, float velocity) { 200 if (mAppsView == null) { 201 return false; // early termination. 202 } 203 204 mContainerVelocity = velocity; 205 206 float shift = Math.min(Math.max(0, mShiftStart + displacement), mShiftRange); 207 setProgress(shift / mShiftRange); 208 209 return true; 210 } 211 212 @Override 213 public void onDragEnd(float velocity, boolean fling) { 214 if (mAppsView == null) { 215 return; // early termination. 216 } 217 218 if (fling) { 219 if (velocity < 0) { 220 calculateDuration(velocity, mAppsView.getTranslationY()); 221 222 if (!mLauncher.isAllAppsVisible()) { 223 mLauncher.getUserEventDispatcher().logActionOnContainer( 224 LauncherLogProto.Action.FLING, 225 LauncherLogProto.Action.UP, 226 LauncherLogProto.HOTSEAT); 227 } 228 mLauncher.showAppsView(true /* animated */, 229 false /* updatePredictedApps */, 230 false /* focusSearchBar */); 231 } else { 232 calculateDuration(velocity, Math.abs(mShiftRange - mAppsView.getTranslationY())); 233 mLauncher.showWorkspace(true); 234 } 235 // snap to top or bottom using the release velocity 236 } else { 237 if (mAppsView.getTranslationY() > mShiftRange / 2) { 238 calculateDuration(velocity, Math.abs(mShiftRange - mAppsView.getTranslationY())); 239 mLauncher.showWorkspace(true); 240 } else { 241 calculateDuration(velocity, Math.abs(mAppsView.getTranslationY())); 242 if (!mLauncher.isAllAppsVisible()) { 243 mLauncher.getUserEventDispatcher().logActionOnContainer( 244 LauncherLogProto.Action.SWIPE, 245 LauncherLogProto.Action.UP, 246 LauncherLogProto.HOTSEAT); 247 } 248 mLauncher.showAppsView(true, /* animated */ 249 false /* updatePredictedApps */, 250 false /* focusSearchBar */); 251 } 252 } 253 } 254 255 public boolean isTransitioning() { 256 return mDetector.isDraggingOrSettling(); 257 } 258 259 /** 260 * @param start {@code true} if start of new drag. 261 */ 262 public void preparePull(boolean start) { 263 if (start) { 264 // Initialize values that should not change until #onDragEnd 265 mStatusBarHeight = mLauncher.getDragLayer().getInsets().top; 266 mHotseat.setVisibility(View.VISIBLE); 267 mHotseatBackgroundColor = mHotseat.getBackgroundDrawableColor(); 268 mHotseat.setBackgroundTransparent(true /* transparent */); 269 if (!mLauncher.isAllAppsVisible()) { 270 mLauncher.tryAndUpdatePredictedApps(); 271 mAppsView.setVisibility(View.VISIBLE); 272 mAppsView.setRevealDrawableColor(mHotseatBackgroundColor); 273 } 274 } 275 } 276 277 private void updateLightStatusBar(float shift) { 278 // Do not modify status bar on landscape as all apps is not full bleed. 279 if (mLauncher.getDeviceProfile().isVerticalBarLayout()) { 280 return; 281 } 282 // Use a light status bar (dark icons) if all apps is behind at least half of the status 283 // bar. If the status bar is already light due to wallpaper extraction, keep it that way. 284 boolean forceLight = shift <= mStatusBarHeight / 2; 285 mLauncher.activateLightStatusBar(forceLight); 286 } 287 288 /** 289 * @param progress value between 0 and 1, 0 shows all apps and 1 shows workspace 290 */ 291 public void setProgress(float progress) { 292 float shiftPrevious = mProgress * mShiftRange; 293 mProgress = progress; 294 float shiftCurrent = progress * mShiftRange; 295 296 float workspaceHotseatAlpha = Utilities.boundToRange(progress, 0f, 1f); 297 float alpha = 1 - workspaceHotseatAlpha; 298 float interpolation = mAccelInterpolator.getInterpolation(workspaceHotseatAlpha); 299 300 int color = (Integer) mEvaluator.evaluate(mDecelInterpolator.getInterpolation(alpha), 301 mHotseatBackgroundColor, mAllAppsBackgroundColor); 302 int bgAlpha = Color.alpha((int) mEvaluator.evaluate(alpha, 303 mHotseatBackgroundColor, mAllAppsBackgroundColor)); 304 305 mAppsView.setRevealDrawableColor(ColorUtils.setAlphaComponent(color, bgAlpha)); 306 mAppsView.getContentView().setAlpha(alpha); 307 mAppsView.setTranslationY(shiftCurrent); 308 309 if (!mLauncher.getDeviceProfile().isVerticalBarLayout()) { 310 mWorkspace.setHotseatTranslationAndAlpha(Workspace.Direction.Y, -mShiftRange + shiftCurrent, 311 interpolation); 312 } else { 313 mWorkspace.setHotseatTranslationAndAlpha(Workspace.Direction.Y, 314 PARALLAX_COEFFICIENT * (-mShiftRange + shiftCurrent), 315 interpolation); 316 } 317 318 if (mIsTranslateWithoutWorkspace) { 319 return; 320 } 321 mWorkspace.setWorkspaceYTranslationAndAlpha( 322 PARALLAX_COEFFICIENT * (-mShiftRange + shiftCurrent), interpolation); 323 324 if (!mDetector.isDraggingState()) { 325 mContainerVelocity = mDetector.computeVelocity(shiftCurrent - shiftPrevious, 326 System.currentTimeMillis()); 327 } 328 329 mCaretController.updateCaret(progress, mContainerVelocity, mDetector.isDraggingState()); 330 updateLightStatusBar(shiftCurrent); 331 } 332 333 public float getProgress() { 334 return mProgress; 335 } 336 337 private void calculateDuration(float velocity, float disp) { 338 // TODO: make these values constants after tuning. 339 float velocityDivisor = Math.max(2f, Math.abs(0.5f * velocity)); 340 float travelDistance = Math.max(0.2f, disp / mShiftRange); 341 mAnimationDuration = (long) Math.max(100, ANIMATION_DURATION / velocityDivisor * travelDistance); 342 if (DBG) { 343 Log.d(TAG, String.format("calculateDuration=%d, v=%f, d=%f", mAnimationDuration, velocity, disp)); 344 } 345 } 346 347 public boolean animateToAllApps(AnimatorSet animationOut, long duration) { 348 boolean shouldPost = true; 349 if (animationOut == null) { 350 return shouldPost; 351 } 352 Interpolator interpolator; 353 if (mDetector.isIdleState()) { 354 preparePull(true); 355 mAnimationDuration = duration; 356 mShiftStart = mAppsView.getTranslationY(); 357 interpolator = mFastOutSlowInInterpolator; 358 } else { 359 mScrollInterpolator.setVelocityAtZero(Math.abs(mContainerVelocity)); 360 interpolator = mScrollInterpolator; 361 float nextFrameProgress = mProgress + mContainerVelocity * SINGLE_FRAME_MS / mShiftRange; 362 if (nextFrameProgress >= 0f) { 363 mProgress = nextFrameProgress; 364 } 365 shouldPost = false; 366 } 367 368 ObjectAnimator driftAndAlpha = ObjectAnimator.ofFloat(this, "progress", 369 mProgress, 0f); 370 driftAndAlpha.setDuration(mAnimationDuration); 371 driftAndAlpha.setInterpolator(interpolator); 372 animationOut.play(driftAndAlpha); 373 374 animationOut.addListener(new AnimatorListenerAdapter() { 375 boolean canceled = false; 376 377 @Override 378 public void onAnimationCancel(Animator animation) { 379 canceled = true; 380 } 381 382 @Override 383 public void onAnimationEnd(Animator animation) { 384 if (canceled) { 385 return; 386 } else { 387 finishPullUp(); 388 cleanUpAnimation(); 389 mDetector.finishedScrolling(); 390 } 391 } 392 }); 393 mCurrentAnimation = animationOut; 394 return shouldPost; 395 } 396 397 public void showDiscoveryBounce() { 398 // cancel existing animation in case user locked and unlocked at a super human speed. 399 cancelDiscoveryAnimation(); 400 401 // assumption is that this variable is always null 402 mDiscoBounceAnimation = (AnimatorSet) AnimatorInflater.loadAnimator(mLauncher, 403 R.anim.discovery_bounce); 404 mDiscoBounceAnimation.addListener(new AnimatorListenerAdapter() { 405 @Override 406 public void onAnimationStart(Animator animator) { 407 mIsTranslateWithoutWorkspace = true; 408 preparePull(true); 409 } 410 411 @Override 412 public void onAnimationEnd(Animator animator) { 413 finishPullDown(); 414 mDiscoBounceAnimation = null; 415 mIsTranslateWithoutWorkspace = false; 416 } 417 }); 418 mDiscoBounceAnimation.setTarget(this); 419 mAppsView.post(new Runnable() { 420 @Override 421 public void run() { 422 if (mDiscoBounceAnimation == null) { 423 return; 424 } 425 mDiscoBounceAnimation.start(); 426 } 427 }); 428 } 429 430 public boolean animateToWorkspace(AnimatorSet animationOut, long duration) { 431 boolean shouldPost = true; 432 if (animationOut == null) { 433 return shouldPost; 434 } 435 Interpolator interpolator; 436 if (mDetector.isIdleState()) { 437 preparePull(true); 438 mAnimationDuration = duration; 439 mShiftStart = mAppsView.getTranslationY(); 440 interpolator = mFastOutSlowInInterpolator; 441 } else { 442 mScrollInterpolator.setVelocityAtZero(Math.abs(mContainerVelocity)); 443 interpolator = mScrollInterpolator; 444 float nextFrameProgress = mProgress + mContainerVelocity * SINGLE_FRAME_MS / mShiftRange; 445 if (nextFrameProgress <= 1f) { 446 mProgress = nextFrameProgress; 447 } 448 shouldPost = false; 449 } 450 451 ObjectAnimator driftAndAlpha = ObjectAnimator.ofFloat(this, "progress", 452 mProgress, 1f); 453 driftAndAlpha.setDuration(mAnimationDuration); 454 driftAndAlpha.setInterpolator(interpolator); 455 animationOut.play(driftAndAlpha); 456 457 animationOut.addListener(new AnimatorListenerAdapter() { 458 boolean canceled = false; 459 460 @Override 461 public void onAnimationCancel(Animator animation) { 462 canceled = true; 463 } 464 465 @Override 466 public void onAnimationEnd(Animator animation) { 467 if (canceled) { 468 return; 469 } else { 470 finishPullDown(); 471 cleanUpAnimation(); 472 mDetector.finishedScrolling(); 473 } 474 } 475 }); 476 mCurrentAnimation = animationOut; 477 return shouldPost; 478 } 479 480 public void finishPullUp() { 481 mHotseat.setVisibility(View.INVISIBLE); 482 setProgress(0f); 483 } 484 485 public void finishPullDown() { 486 mAppsView.setVisibility(View.INVISIBLE); 487 mHotseat.setBackgroundTransparent(false /* transparent */); 488 mHotseat.setVisibility(View.VISIBLE); 489 mAppsView.reset(); 490 setProgress(1f); 491 } 492 493 private void cancelAnimation() { 494 if (mCurrentAnimation != null) { 495 mCurrentAnimation.cancel(); 496 mCurrentAnimation = null; 497 } 498 cancelDiscoveryAnimation(); 499 } 500 501 public void cancelDiscoveryAnimation() { 502 if (mDiscoBounceAnimation == null) { 503 return; 504 } 505 mDiscoBounceAnimation.cancel(); 506 mDiscoBounceAnimation = null; 507 } 508 509 private void cleanUpAnimation() { 510 mCurrentAnimation = null; 511 } 512 513 public void setupViews(AllAppsContainerView appsView, Hotseat hotseat, Workspace workspace) { 514 mAppsView = appsView; 515 mHotseat = hotseat; 516 mWorkspace = workspace; 517 mHotseat.addOnLayoutChangeListener(this); 518 mHotseat.bringToFront(); 519 mCaretController = new AllAppsCaretController( 520 mWorkspace.getPageIndicator().getCaretDrawable(), mLauncher); 521 } 522 523 @Override 524 public void onLayoutChange(View v, int left, int top, int right, int bottom, 525 int oldLeft, int oldTop, int oldRight, int oldBottom) { 526 if (!mLauncher.getDeviceProfile().isVerticalBarLayout()) { 527 mShiftRange = top; 528 } else { 529 mShiftRange = bottom; 530 } 531 setProgress(mProgress); 532 } 533 534 static class ScrollInterpolator implements Interpolator { 535 536 boolean mSteeper; 537 538 public void setVelocityAtZero(float velocity) { 539 mSteeper = velocity > FAST_FLING_PX_MS; 540 } 541 542 public float getInterpolation(float t) { 543 t -= 1.0f; 544 float output = t * t * t; 545 if (mSteeper) { 546 output *= t * t; // Make interpolation initial slope steeper 547 } 548 return output + 1; 549 } 550 } 551 } 552