1 package com.android.contacts.widget; 2 3 import com.android.contacts.R; 4 import com.android.contacts.common.compat.CompatUtils; 5 import com.android.contacts.compat.EdgeEffectCompat; 6 import com.android.contacts.quickcontact.ExpandingEntryCardView; 7 import com.android.contacts.test.NeededForReflection; 8 import com.android.contacts.util.SchedulingUtils; 9 10 import android.animation.Animator; 11 import android.animation.Animator.AnimatorListener; 12 import android.animation.AnimatorListenerAdapter; 13 import android.animation.ObjectAnimator; 14 import android.animation.ValueAnimator; 15 import android.animation.ValueAnimator.AnimatorUpdateListener; 16 import android.content.Context; 17 import android.content.res.TypedArray; 18 import android.graphics.Canvas; 19 import android.graphics.Color; 20 import android.graphics.ColorMatrix; 21 import android.graphics.ColorMatrixColorFilter; 22 import android.graphics.drawable.GradientDrawable; 23 import android.hardware.display.DisplayManager; 24 import android.os.Trace; 25 import android.support.v4.view.ViewCompat; 26 import android.support.v4.view.animation.PathInterpolatorCompat; 27 import android.util.AttributeSet; 28 import android.util.TypedValue; 29 import android.view.Display; 30 import android.view.Gravity; 31 import android.view.MotionEvent; 32 import android.view.VelocityTracker; 33 import android.view.View; 34 import android.view.ViewGroup; 35 import android.view.ViewConfiguration; 36 import android.view.animation.AnimationUtils; 37 import android.view.animation.Interpolator; 38 import android.widget.EdgeEffect; 39 import android.widget.FrameLayout; 40 import android.widget.LinearLayout; 41 import android.widget.Scroller; 42 import android.widget.ScrollView; 43 import android.widget.TextView; 44 import android.widget.Toolbar; 45 46 /** 47 * A custom {@link ViewGroup} that operates similarly to a {@link ScrollView}, except with multiple 48 * subviews. These subviews are scrolled or shrinked one at a time, until each reaches their 49 * minimum or maximum value. 50 * 51 * MultiShrinkScroller is designed for a specific problem. As such, this class is designed to be 52 * used with a specific layout file: quickcontact_activity.xml. MultiShrinkScroller expects subviews 53 * with specific ID values. 54 * 55 * MultiShrinkScroller's code is heavily influenced by ScrollView. Nonetheless, several ScrollView 56 * features are missing. For example: handling of KEYCODES, OverScroll bounce and saving 57 * scroll state in savedInstanceState bundles. 58 * 59 * Before copying this approach to nested scrolling, consider whether something simpler & less 60 * customized will work for you. For example, see the re-usable StickyHeaderListView used by 61 * WifiSetupActivity (very nice). Alternatively, check out Google+'s cover photo scrolling or 62 * Android L's built in nested scrolling support. I thought I needed a more custom ViewGroup in 63 * order to track velocity, modify EdgeEffect color & perform the originally specified animations. 64 * As a result this ViewGroup has non-standard talkback and keyboard support. 65 */ 66 public class MultiShrinkScroller extends FrameLayout { 67 68 /** 69 * 1000 pixels per millisecond. Ie, 1 pixel per second. 70 */ 71 private static final int PIXELS_PER_SECOND = 1000; 72 73 /** 74 * Length of the acceleration animations. This value was taken from ValueAnimator.java. 75 */ 76 private static final int EXIT_FLING_ANIMATION_DURATION_MS = 250; 77 78 /** 79 * In portrait mode, the height:width ratio of the photo's starting height. 80 */ 81 private static final float INTERMEDIATE_HEADER_HEIGHT_RATIO = 0.6f; 82 83 /** 84 * Color blending will only be performed on the contact photo once the toolbar is compressed 85 * to this ratio of its full height. 86 */ 87 private static final float COLOR_BLENDING_START_RATIO = 0.5f; 88 89 private static final float SPRING_DAMPENING_FACTOR = 0.01f; 90 91 /** 92 * When displaying a letter tile drawable, this alpha value should be used at the intermediate 93 * toolbar height. 94 */ 95 private static final float DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA = 0.8f; 96 97 private float[] mLastEventPosition = { 0, 0 }; 98 private VelocityTracker mVelocityTracker; 99 private boolean mIsBeingDragged = false; 100 private boolean mReceivedDown = false; 101 /** 102 * Did the current downwards fling/scroll-animation start while we were fullscreen? 103 */ 104 private boolean mIsFullscreenDownwardsFling = false; 105 106 private ScrollView mScrollView; 107 private View mScrollViewChild; 108 private View mToolbar; 109 private QuickContactImageView mPhotoView; 110 private View mPhotoViewContainer; 111 private View mTransparentView; 112 private MultiShrinkScrollerListener mListener; 113 private TextView mLargeTextView; 114 private TextView mPhoneticNameView; 115 private View mTitleAndPhoneticNameView; 116 private View mPhotoTouchInterceptOverlay; 117 /** Contains desired size & vertical offset of the title, once the header is fully compressed */ 118 private TextView mInvisiblePlaceholderTextView; 119 private View mTitleGradientView; 120 private View mActionBarGradientView; 121 private View mStartColumn; 122 private int mHeaderTintColor; 123 private int mMaximumHeaderHeight; 124 private int mMinimumHeaderHeight; 125 /** 126 * When the contact photo is tapped, it is resized to max size or this size. This value also 127 * sometimes represents the maximum achievable header size achieved by scrolling. To enforce 128 * this maximum in scrolling logic, always access this value via 129 * {@link #getMaximumScrollableHeaderHeight}. 130 */ 131 private int mIntermediateHeaderHeight; 132 /** 133 * If true, regular scrolling can expand the header beyond mIntermediateHeaderHeight. The 134 * header, that contains the contact photo, can expand to a height equal its width. 135 */ 136 private boolean mIsOpenContactSquare; 137 private int mMaximumHeaderTextSize; 138 private int mCollapsedTitleBottomMargin; 139 private int mCollapsedTitleStartMargin; 140 private int mMinimumPortraitHeaderHeight; 141 private int mMaximumPortraitHeaderHeight; 142 /** 143 * True once the header has touched the top of the screen at least once. 144 */ 145 private boolean mHasEverTouchedTheTop; 146 private boolean mIsTouchDisabledForDismissAnimation; 147 private boolean mIsTouchDisabledForSuppressLayout; 148 149 private final Scroller mScroller; 150 private final EdgeEffect mEdgeGlowBottom; 151 private final EdgeEffect mEdgeGlowTop; 152 private final int mTouchSlop; 153 private final int mMaximumVelocity; 154 private final int mMinimumVelocity; 155 private final int mDismissDistanceOnScroll; 156 private final int mDismissDistanceOnRelease; 157 private final int mSnapToTopSlopHeight; 158 private final int mTransparentStartHeight; 159 private final int mMaximumTitleMargin; 160 private final float mToolbarElevation; 161 private final boolean mIsTwoPanel; 162 private final float mLandscapePhotoRatio; 163 private final int mActionBarSize; 164 165 // Objects used to perform color filtering on the header. These are stored as fields for 166 // the sole purpose of avoiding "new" operations inside animation loops. 167 private final ColorMatrix mWhitenessColorMatrix = new ColorMatrix(); 168 private final ColorMatrix mColorMatrix = new ColorMatrix(); 169 private final float[] mAlphaMatrixValues = { 170 0, 0, 0, 0, 0, 171 0, 0, 0, 0, 0, 172 0, 0, 0, 0, 0, 173 0, 0, 0, 1, 0 174 }; 175 private final ColorMatrix mMultiplyBlendMatrix = new ColorMatrix(); 176 private final float[] mMultiplyBlendMatrixValues = { 177 0, 0, 0, 0, 0, 178 0, 0, 0, 0, 0, 179 0, 0, 0, 0, 0, 180 0, 0, 0, 1, 0 181 }; 182 183 private final Interpolator mTextSizePathInterpolator = 184 PathInterpolatorCompat.create(0.16f, 0.4f, 0.2f, 1); 185 186 private final int[] mGradientColors = new int[] {0,0x88000000}; 187 private GradientDrawable mTitleGradientDrawable = new GradientDrawable( 188 GradientDrawable.Orientation.TOP_BOTTOM, mGradientColors); 189 private GradientDrawable mActionBarGradientDrawable = new GradientDrawable( 190 GradientDrawable.Orientation.BOTTOM_TOP, mGradientColors); 191 192 public interface MultiShrinkScrollerListener { 193 void onScrolledOffBottom(); 194 195 void onStartScrollOffBottom(); 196 197 void onTransparentViewHeightChange(float ratio); 198 199 void onEntranceAnimationDone(); 200 201 void onEnterFullscreen(); 202 203 void onExitFullscreen(); 204 } 205 206 private final AnimatorListener mSnapToBottomListener = new AnimatorListenerAdapter() { 207 @Override 208 public void onAnimationEnd(Animator animation) { 209 if (getScrollUntilOffBottom() > 0 && mListener != null) { 210 // Due to a rounding error, after the animation finished we haven't fully scrolled 211 // off the screen. Lie to the listener: tell it that we did scroll off the screen. 212 mListener.onScrolledOffBottom(); 213 // No other messages need to be sent to the listener. 214 mListener = null; 215 } 216 } 217 }; 218 219 /** 220 * Interpolator from android.support.v4.view.ViewPager. Snappier and more elastic feeling 221 * than the default interpolator. 222 */ 223 private static final Interpolator sInterpolator = new Interpolator() { 224 225 /** 226 * {@inheritDoc} 227 */ 228 @Override 229 public float getInterpolation(float t) { 230 t -= 1.0f; 231 return t * t * t * t * t + 1.0f; 232 } 233 }; 234 235 public MultiShrinkScroller(Context context) { 236 this(context, null); 237 } 238 239 public MultiShrinkScroller(Context context, AttributeSet attrs) { 240 this(context, attrs, 0); 241 } 242 243 public MultiShrinkScroller(Context context, AttributeSet attrs, int defStyleAttr) { 244 super(context, attrs, defStyleAttr); 245 246 final ViewConfiguration configuration = ViewConfiguration.get(context); 247 setFocusable(false); 248 // Drawing must be enabled in order to support EdgeEffect 249 setWillNotDraw(/* willNotDraw = */ false); 250 251 mEdgeGlowBottom = new EdgeEffect(context); 252 mEdgeGlowTop = new EdgeEffect(context); 253 mScroller = new Scroller(context, sInterpolator); 254 mTouchSlop = configuration.getScaledTouchSlop(); 255 mMinimumVelocity = configuration.getScaledMinimumFlingVelocity(); 256 mMaximumVelocity = configuration.getScaledMaximumFlingVelocity(); 257 mTransparentStartHeight = (int) getResources().getDimension( 258 R.dimen.quickcontact_starting_empty_height); 259 mToolbarElevation = getResources().getDimension( 260 R.dimen.quick_contact_toolbar_elevation); 261 mIsTwoPanel = getResources().getBoolean(R.bool.quickcontact_two_panel); 262 mMaximumTitleMargin = (int) getResources().getDimension( 263 R.dimen.quickcontact_title_initial_margin); 264 265 mDismissDistanceOnScroll = (int) getResources().getDimension( 266 R.dimen.quickcontact_dismiss_distance_on_scroll); 267 mDismissDistanceOnRelease = (int) getResources().getDimension( 268 R.dimen.quickcontact_dismiss_distance_on_release); 269 mSnapToTopSlopHeight = (int) getResources().getDimension( 270 R.dimen.quickcontact_snap_to_top_slop_height); 271 272 final TypedValue photoRatio = new TypedValue(); 273 getResources().getValue(R.dimen.quickcontact_landscape_photo_ratio, photoRatio, 274 /* resolveRefs = */ true); 275 mLandscapePhotoRatio = photoRatio.getFloat(); 276 277 final TypedArray attributeArray = context.obtainStyledAttributes( 278 new int[]{android.R.attr.actionBarSize}); 279 mActionBarSize = attributeArray.getDimensionPixelSize(0, 0); 280 mMinimumHeaderHeight = mActionBarSize; 281 // This value is approximately equal to the portrait ActionBar size. It isn't exactly the 282 // same, since the landscape and portrait ActionBar sizes can be different. 283 mMinimumPortraitHeaderHeight = mMinimumHeaderHeight; 284 attributeArray.recycle(); 285 } 286 287 /** 288 * This method must be called inside the Activity's OnCreate. 289 */ 290 public void initialize(MultiShrinkScrollerListener listener, boolean isOpenContactSquare) { 291 mScrollView = (ScrollView) findViewById(R.id.content_scroller); 292 mScrollViewChild = findViewById(R.id.card_container); 293 mToolbar = findViewById(R.id.toolbar_parent); 294 mPhotoViewContainer = findViewById(R.id.toolbar_parent); 295 mTransparentView = findViewById(R.id.transparent_view); 296 mLargeTextView = (TextView) findViewById(R.id.large_title); 297 mPhoneticNameView = (TextView) findViewById(R.id.phonetic_name); 298 mTitleAndPhoneticNameView = findViewById(R.id.title_and_phonetic_name); 299 mInvisiblePlaceholderTextView = (TextView) findViewById(R.id.placeholder_textview); 300 mStartColumn = findViewById(R.id.empty_start_column); 301 // Touching the empty space should close the card 302 if (mStartColumn != null) { 303 mStartColumn.setOnClickListener(new OnClickListener() { 304 @Override 305 public void onClick(View v) { 306 scrollOffBottom(); 307 } 308 }); 309 findViewById(R.id.empty_end_column).setOnClickListener(new OnClickListener() { 310 @Override 311 public void onClick(View v) { 312 scrollOffBottom(); 313 } 314 }); 315 } 316 mListener = listener; 317 mIsOpenContactSquare = isOpenContactSquare; 318 319 mPhotoView = (QuickContactImageView) findViewById(R.id.photo); 320 321 mTitleGradientView = findViewById(R.id.title_gradient); 322 mTitleGradientView.setBackground(mTitleGradientDrawable); 323 mActionBarGradientView = findViewById(R.id.action_bar_gradient); 324 mActionBarGradientView.setBackground(mActionBarGradientDrawable); 325 mCollapsedTitleStartMargin = ((Toolbar) findViewById(R.id.toolbar)).getContentInsetStart(); 326 327 mPhotoTouchInterceptOverlay = findViewById(R.id.photo_touch_intercept_overlay); 328 if (!mIsTwoPanel) { 329 mPhotoTouchInterceptOverlay.setOnClickListener(new OnClickListener() { 330 @Override 331 public void onClick(View v) { 332 expandHeader(); 333 } 334 }); 335 } 336 337 SchedulingUtils.doOnPreDraw(this, /* drawNextFrame = */ false, new Runnable() { 338 @Override 339 public void run() { 340 if (!mIsTwoPanel) { 341 // We never want the height of the photo view to exceed its width. 342 mMaximumHeaderHeight = mPhotoViewContainer.getWidth(); 343 mIntermediateHeaderHeight = (int) (mMaximumHeaderHeight 344 * INTERMEDIATE_HEADER_HEIGHT_RATIO); 345 } 346 mMaximumPortraitHeaderHeight = mIsTwoPanel ? getHeight() 347 : mPhotoViewContainer.getWidth(); 348 setHeaderHeight(getMaximumScrollableHeaderHeight()); 349 mMaximumHeaderTextSize = mTitleAndPhoneticNameView.getHeight(); 350 if (mIsTwoPanel) { 351 mMaximumHeaderHeight = getHeight(); 352 mMinimumHeaderHeight = mMaximumHeaderHeight; 353 mIntermediateHeaderHeight = mMaximumHeaderHeight; 354 355 // Permanently set photo width and height. 356 final ViewGroup.LayoutParams photoLayoutParams 357 = mPhotoViewContainer.getLayoutParams(); 358 photoLayoutParams.height = mMaximumHeaderHeight; 359 photoLayoutParams.width = (int) (mMaximumHeaderHeight * mLandscapePhotoRatio); 360 mPhotoViewContainer.setLayoutParams(photoLayoutParams); 361 362 // Permanently set title width and margin. 363 final FrameLayout.LayoutParams largeTextLayoutParams 364 = (FrameLayout.LayoutParams) mTitleAndPhoneticNameView 365 .getLayoutParams(); 366 largeTextLayoutParams.width = photoLayoutParams.width - 367 largeTextLayoutParams.leftMargin - largeTextLayoutParams.rightMargin; 368 largeTextLayoutParams.gravity = Gravity.BOTTOM | Gravity.START; 369 mTitleAndPhoneticNameView.setLayoutParams(largeTextLayoutParams); 370 } else { 371 // Set the width of mLargeTextView as if it was nested inside 372 // mPhotoViewContainer. 373 mLargeTextView.setWidth(mPhotoViewContainer.getWidth() 374 - 2 * mMaximumTitleMargin); 375 mPhoneticNameView.setWidth(mPhotoViewContainer.getWidth() 376 - 2 * mMaximumTitleMargin); 377 } 378 379 calculateCollapsedLargeTitlePadding(); 380 updateHeaderTextSizeAndMargin(); 381 configureGradientViewHeights(); 382 } 383 }); 384 } 385 386 private void configureGradientViewHeights() { 387 final FrameLayout.LayoutParams actionBarGradientLayoutParams 388 = (FrameLayout.LayoutParams) mActionBarGradientView.getLayoutParams(); 389 actionBarGradientLayoutParams.height = mActionBarSize; 390 mActionBarGradientView.setLayoutParams(actionBarGradientLayoutParams); 391 final FrameLayout.LayoutParams titleGradientLayoutParams 392 = (FrameLayout.LayoutParams) mTitleGradientView.getLayoutParams(); 393 final float TITLE_GRADIENT_SIZE_COEFFICIENT = 1.25f; 394 final FrameLayout.LayoutParams largeTextLayoutParms 395 = (FrameLayout.LayoutParams) mTitleAndPhoneticNameView.getLayoutParams(); 396 titleGradientLayoutParams.height = (int) ((mTitleAndPhoneticNameView.getHeight() 397 + largeTextLayoutParms.bottomMargin) * TITLE_GRADIENT_SIZE_COEFFICIENT); 398 mTitleGradientView.setLayoutParams(titleGradientLayoutParams); 399 } 400 401 public void setTitle(String title, boolean isPhoneNumber) { 402 mLargeTextView.setText(title); 403 // We have a phone number as "mLargeTextView" so make it always LTR. 404 if (isPhoneNumber) { 405 mLargeTextView.setTextDirection(View.TEXT_DIRECTION_LTR); 406 } 407 mPhotoTouchInterceptOverlay.setContentDescription(title); 408 } 409 410 public void setPhoneticName(String phoneticName) { 411 // Set phonetic name only when it was gone before or got changed. 412 if (mPhoneticNameView.getVisibility() == View.VISIBLE 413 && phoneticName.equals(mPhoneticNameView.getText())) { 414 return; 415 } 416 mPhoneticNameView.setText(phoneticName); 417 // Every time the phonetic name is changed, set mPhoneticNameView as visible, 418 // in case it just changed from Visibility=GONE. 419 mPhoneticNameView.setVisibility(View.VISIBLE); 420 // TODO try not using initialize() to refresh phonetic name view: b/27410518 421 initialize(mListener, mIsOpenContactSquare); 422 } 423 424 public void setPhoneticNameGone() { 425 // Remove phonetic name only when it was visible before. 426 if (mPhoneticNameView.getVisibility() == View.GONE) { 427 return; 428 } 429 mPhoneticNameView.setVisibility(View.GONE); 430 // Initialize to make Visibility work. 431 // TODO try not using initialize() to refresh phonetic name view: b/27410518 432 initialize(mListener, mIsOpenContactSquare); 433 } 434 435 @Override 436 public boolean onInterceptTouchEvent(MotionEvent event) { 437 if (mVelocityTracker == null) { 438 mVelocityTracker = VelocityTracker.obtain(); 439 } 440 mVelocityTracker.addMovement(event); 441 442 // The only time we want to intercept touch events is when we are being dragged. 443 return shouldStartDrag(event); 444 } 445 446 private boolean shouldStartDrag(MotionEvent event) { 447 if (mIsTouchDisabledForDismissAnimation || mIsTouchDisabledForSuppressLayout) return false; 448 449 450 if (mIsBeingDragged) { 451 mIsBeingDragged = false; 452 return false; 453 } 454 455 switch (event.getAction()) { 456 // If we are in the middle of a fling and there is a down event, we'll steal it and 457 // start a drag. 458 case MotionEvent.ACTION_DOWN: 459 updateLastEventPosition(event); 460 if (!mScroller.isFinished()) { 461 startDrag(); 462 return true; 463 } else { 464 mReceivedDown = true; 465 } 466 break; 467 468 // Otherwise, we will start a drag if there is enough motion in the direction we are 469 // capable of scrolling. 470 case MotionEvent.ACTION_MOVE: 471 if (motionShouldStartDrag(event)) { 472 updateLastEventPosition(event); 473 startDrag(); 474 return true; 475 } 476 break; 477 } 478 479 return false; 480 } 481 482 @Override 483 public boolean onTouchEvent(MotionEvent event) { 484 if (mIsTouchDisabledForDismissAnimation || mIsTouchDisabledForSuppressLayout) return true; 485 486 final int action = event.getAction(); 487 488 if (mVelocityTracker == null) { 489 mVelocityTracker = VelocityTracker.obtain(); 490 } 491 mVelocityTracker.addMovement(event); 492 493 if (!mIsBeingDragged) { 494 if (shouldStartDrag(event)) { 495 return true; 496 } 497 498 if (action == MotionEvent.ACTION_UP && mReceivedDown) { 499 mReceivedDown = false; 500 return performClick(); 501 } 502 return true; 503 } 504 505 switch (action) { 506 case MotionEvent.ACTION_MOVE: 507 final float delta = updatePositionAndComputeDelta(event); 508 scrollTo(0, getScroll() + (int) delta); 509 mReceivedDown = false; 510 511 if (mIsBeingDragged) { 512 final int distanceFromMaxScrolling = getMaximumScrollUpwards() - getScroll(); 513 if (delta > distanceFromMaxScrolling) { 514 // The ScrollView is being pulled upwards while there is no more 515 // content offscreen, and the view port is already fully expanded. 516 EdgeEffectCompat.onPull(mEdgeGlowBottom, delta / getHeight(), 517 1 - event.getX() / getWidth()); 518 } 519 520 if (!mEdgeGlowBottom.isFinished()) { 521 postInvalidateOnAnimation(); 522 } 523 524 if (shouldDismissOnScroll()) { 525 scrollOffBottom(); 526 } 527 528 } 529 break; 530 531 case MotionEvent.ACTION_UP: 532 case MotionEvent.ACTION_CANCEL: 533 stopDrag(action == MotionEvent.ACTION_CANCEL); 534 mReceivedDown = false; 535 break; 536 } 537 538 return true; 539 } 540 541 public void setHeaderTintColor(int color) { 542 mHeaderTintColor = color; 543 updatePhotoTintAndDropShadow(); 544 if (CompatUtils.isLollipopCompatible()) { 545 // Use the same amount of alpha on the new tint color as the previous tint color. 546 final int edgeEffectAlpha = Color.alpha(mEdgeGlowBottom.getColor()); 547 mEdgeGlowBottom.setColor((color & 0xffffff) | Color.argb(edgeEffectAlpha, 0, 0, 0)); 548 mEdgeGlowTop.setColor(mEdgeGlowBottom.getColor()); 549 } 550 } 551 552 /** 553 * Expand to maximum size. 554 */ 555 private void expandHeader() { 556 if (getHeaderHeight() != mMaximumHeaderHeight) { 557 final ObjectAnimator animator = ObjectAnimator.ofInt(this, "headerHeight", 558 mMaximumHeaderHeight); 559 animator.setDuration(ExpandingEntryCardView.DURATION_EXPAND_ANIMATION_CHANGE_BOUNDS); 560 animator.start(); 561 // Scroll nested scroll view to its top 562 if (mScrollView.getScrollY() != 0) { 563 ObjectAnimator.ofInt(mScrollView, "scrollY", -mScrollView.getScrollY()).start(); 564 } 565 } 566 } 567 568 private void startDrag() { 569 mIsBeingDragged = true; 570 mScroller.abortAnimation(); 571 } 572 573 private void stopDrag(boolean cancelled) { 574 mIsBeingDragged = false; 575 if (!cancelled && getChildCount() > 0) { 576 final float velocity = getCurrentVelocity(); 577 if (velocity > mMinimumVelocity || velocity < -mMinimumVelocity) { 578 fling(-velocity); 579 onDragFinished(mScroller.getFinalY() - mScroller.getStartY()); 580 } else { 581 onDragFinished(/* flingDelta = */ 0); 582 } 583 } else { 584 onDragFinished(/* flingDelta = */ 0); 585 } 586 587 if (mVelocityTracker != null) { 588 mVelocityTracker.recycle(); 589 mVelocityTracker = null; 590 } 591 592 mEdgeGlowBottom.onRelease(); 593 } 594 595 private void onDragFinished(int flingDelta) { 596 if (getTransparentViewHeight() <= 0) { 597 // Don't perform any snapping if quick contacts is full screen. 598 return; 599 } 600 if (!snapToTopOnDragFinished(flingDelta)) { 601 // The drag/fling won't result in the content at the top of the Window. Consider 602 // snapping the content to the bottom of the window. 603 snapToBottomOnDragFinished(); 604 } 605 } 606 607 /** 608 * If needed, snap the subviews to the top of the Window. 609 * 610 * @return TRUE if QuickContacts will snap/fling to to top after this method call. 611 */ 612 private boolean snapToTopOnDragFinished(int flingDelta) { 613 if (!mHasEverTouchedTheTop) { 614 // If the current fling is predicted to scroll past the top, then we don't need to snap 615 // to the top. However, if the fling only flings past the top by a tiny amount, 616 // it will look nicer to snap than to fling. 617 final float predictedScrollPastTop = getTransparentViewHeight() - flingDelta; 618 if (predictedScrollPastTop < -mSnapToTopSlopHeight) { 619 return false; 620 } 621 622 if (getTransparentViewHeight() <= mTransparentStartHeight) { 623 // We are above the starting scroll position so snap to the top. 624 mScroller.forceFinished(true); 625 smoothScrollBy(getTransparentViewHeight()); 626 return true; 627 } 628 return false; 629 } 630 if (getTransparentViewHeight() < mDismissDistanceOnRelease) { 631 mScroller.forceFinished(true); 632 smoothScrollBy(getTransparentViewHeight()); 633 return true; 634 } 635 return false; 636 } 637 638 /** 639 * If needed, scroll all the subviews off the bottom of the Window. 640 */ 641 private void snapToBottomOnDragFinished() { 642 if (mHasEverTouchedTheTop) { 643 if (getTransparentViewHeight() > mDismissDistanceOnRelease) { 644 scrollOffBottom(); 645 } 646 return; 647 } 648 if (getTransparentViewHeight() > mTransparentStartHeight) { 649 scrollOffBottom(); 650 } 651 } 652 653 /** 654 * Returns TRUE if we have scrolled far QuickContacts far enough that we should dismiss it 655 * without waiting for the user to finish their drag. 656 */ 657 private boolean shouldDismissOnScroll() { 658 return mHasEverTouchedTheTop && getTransparentViewHeight() > mDismissDistanceOnScroll; 659 } 660 661 /** 662 * Return ratio of non-transparent:viewgroup-height for this viewgroup at the starting position. 663 */ 664 public float getStartingTransparentHeightRatio() { 665 return getTransparentHeightRatio(mTransparentStartHeight); 666 } 667 668 private float getTransparentHeightRatio(int transparentHeight) { 669 final float heightRatio = (float) transparentHeight / getHeight(); 670 // Clamp between [0, 1] in case this is called before height is initialized. 671 return 1.0f - Math.max(Math.min(1.0f, heightRatio), 0f); 672 } 673 674 public void scrollOffBottom() { 675 mIsTouchDisabledForDismissAnimation = true; 676 final Interpolator interpolator = new AcceleratingFlingInterpolator( 677 EXIT_FLING_ANIMATION_DURATION_MS, getCurrentVelocity(), 678 getScrollUntilOffBottom()); 679 mScroller.forceFinished(true); 680 ObjectAnimator translateAnimation = ObjectAnimator.ofInt(this, "scroll", 681 getScroll() - getScrollUntilOffBottom()); 682 translateAnimation.setRepeatCount(0); 683 translateAnimation.setInterpolator(interpolator); 684 translateAnimation.setDuration(EXIT_FLING_ANIMATION_DURATION_MS); 685 translateAnimation.addListener(mSnapToBottomListener); 686 translateAnimation.start(); 687 if (mListener != null) { 688 mListener.onStartScrollOffBottom(); 689 } 690 } 691 692 /** 693 * @param scrollToCurrentPosition if true, will scroll from the bottom of the screen to the 694 * current position. Otherwise, will scroll from the bottom of the screen to the top of the 695 * screen. 696 */ 697 public void scrollUpForEntranceAnimation(boolean scrollToCurrentPosition) { 698 final int currentPosition = getScroll(); 699 final int bottomScrollPosition = currentPosition 700 - (getHeight() - getTransparentViewHeight()) + 1; 701 final Interpolator interpolator = AnimationUtils.loadInterpolator(getContext(), 702 android.R.interpolator.linear_out_slow_in); 703 final int desiredValue = currentPosition + (scrollToCurrentPosition ? currentPosition 704 : getTransparentViewHeight()); 705 final ObjectAnimator animator = ObjectAnimator.ofInt(this, "scroll", bottomScrollPosition, 706 desiredValue); 707 animator.setInterpolator(interpolator); 708 animator.addUpdateListener(new AnimatorUpdateListener() { 709 @Override 710 public void onAnimationUpdate(ValueAnimator animation) { 711 if (animation.getAnimatedValue().equals(desiredValue) && mListener != null) { 712 mListener.onEntranceAnimationDone(); 713 } 714 } 715 }); 716 animator.start(); 717 } 718 719 @Override 720 public void scrollTo(int x, int y) { 721 final int delta = y - getScroll(); 722 boolean wasFullscreen = getScrollNeededToBeFullScreen() <= 0; 723 if (delta > 0) { 724 scrollUp(delta); 725 } else { 726 scrollDown(delta); 727 } 728 updatePhotoTintAndDropShadow(); 729 updateHeaderTextSizeAndMargin(); 730 final boolean isFullscreen = getScrollNeededToBeFullScreen() <= 0; 731 mHasEverTouchedTheTop |= isFullscreen; 732 if (mListener != null) { 733 if (wasFullscreen && !isFullscreen) { 734 mListener.onExitFullscreen(); 735 } else if (!wasFullscreen && isFullscreen) { 736 mListener.onEnterFullscreen(); 737 } 738 if (!isFullscreen || !wasFullscreen) { 739 mListener.onTransparentViewHeightChange( 740 getTransparentHeightRatio(getTransparentViewHeight())); 741 } 742 } 743 } 744 745 /** 746 * Change the height of the header/toolbar. Do *not* use this outside animations. This was 747 * designed for use by {@link #prepareForShrinkingScrollChild}. 748 */ 749 @NeededForReflection 750 public void setToolbarHeight(int delta) { 751 final ViewGroup.LayoutParams toolbarLayoutParams 752 = mToolbar.getLayoutParams(); 753 toolbarLayoutParams.height = delta; 754 mToolbar.setLayoutParams(toolbarLayoutParams); 755 756 updatePhotoTintAndDropShadow(); 757 updateHeaderTextSizeAndMargin(); 758 } 759 760 @NeededForReflection 761 public int getToolbarHeight() { 762 return mToolbar.getLayoutParams().height; 763 } 764 765 /** 766 * Set the height of the toolbar and update its tint accordingly. 767 */ 768 @NeededForReflection 769 public void setHeaderHeight(int height) { 770 final ViewGroup.LayoutParams toolbarLayoutParams 771 = mToolbar.getLayoutParams(); 772 toolbarLayoutParams.height = height; 773 mToolbar.setLayoutParams(toolbarLayoutParams); 774 updatePhotoTintAndDropShadow(); 775 updateHeaderTextSizeAndMargin(); 776 } 777 778 @NeededForReflection 779 public int getHeaderHeight() { 780 return mToolbar.getLayoutParams().height; 781 } 782 783 @NeededForReflection 784 public void setScroll(int scroll) { 785 scrollTo(0, scroll); 786 } 787 788 /** 789 * Returns the total amount scrolled inside the nested ScrollView + the amount of shrinking 790 * performed on the ToolBar. This is the value inspected by animators. 791 */ 792 @NeededForReflection 793 public int getScroll() { 794 return mTransparentStartHeight - getTransparentViewHeight() 795 + getMaximumScrollableHeaderHeight() - getToolbarHeight() 796 + mScrollView.getScrollY(); 797 } 798 799 private int getMaximumScrollableHeaderHeight() { 800 return mIsOpenContactSquare ? mMaximumHeaderHeight : mIntermediateHeaderHeight; 801 } 802 803 /** 804 * A variant of {@link #getScroll} that pretends the header is never larger than 805 * than mIntermediateHeaderHeight. This function is sometimes needed when making scrolling 806 * decisions that will not change the header size (ie, snapping to the bottom or top). 807 * 808 * When mIsOpenContactSquare is true, this function considers mIntermediateHeaderHeight == 809 * mMaximumHeaderHeight, since snapping decisions will be made relative the full header 810 * size when mIsOpenContactSquare = true. 811 * 812 * This value should never be used in conjunction with {@link #getScroll} values. 813 */ 814 private int getScroll_ignoreOversizedHeaderForSnapping() { 815 return mTransparentStartHeight - getTransparentViewHeight() 816 + Math.max(getMaximumScrollableHeaderHeight() - getToolbarHeight(), 0) 817 + mScrollView.getScrollY(); 818 } 819 820 /** 821 * Amount of transparent space above the header/toolbar. 822 */ 823 public int getScrollNeededToBeFullScreen() { 824 return getTransparentViewHeight(); 825 } 826 827 /** 828 * Return amount of scrolling needed in order for all the visible subviews to scroll off the 829 * bottom. 830 */ 831 private int getScrollUntilOffBottom() { 832 return getHeight() + getScroll_ignoreOversizedHeaderForSnapping() 833 - mTransparentStartHeight; 834 } 835 836 @Override 837 public void computeScroll() { 838 if (mScroller.computeScrollOffset()) { 839 // Examine the fling results in order to activate EdgeEffect and halt flings. 840 final int oldScroll = getScroll(); 841 scrollTo(0, mScroller.getCurrY()); 842 final int delta = mScroller.getCurrY() - oldScroll; 843 final int distanceFromMaxScrolling = getMaximumScrollUpwards() - getScroll(); 844 if (delta > distanceFromMaxScrolling && distanceFromMaxScrolling > 0) { 845 mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity()); 846 } 847 if (mIsFullscreenDownwardsFling && getTransparentViewHeight() > 0) { 848 // Halt the fling once QuickContact's top is on screen. 849 scrollTo(0, getScroll() + getTransparentViewHeight()); 850 mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity()); 851 mScroller.abortAnimation(); 852 mIsFullscreenDownwardsFling = false; 853 } 854 if (!awakenScrollBars()) { 855 // Keep on drawing until the animation has finished. 856 postInvalidateOnAnimation(); 857 } 858 if (mScroller.getCurrY() >= getMaximumScrollUpwards()) { 859 // Halt the fling once QuickContact's bottom is on screen. 860 mScroller.abortAnimation(); 861 mIsFullscreenDownwardsFling = false; 862 } 863 } 864 } 865 866 @Override 867 public void draw(Canvas canvas) { 868 super.draw(canvas); 869 870 final int width = getWidth() - getPaddingLeft() - getPaddingRight(); 871 final int height = getHeight(); 872 873 if (!mEdgeGlowBottom.isFinished()) { 874 final int restoreCount = canvas.save(); 875 876 // Draw the EdgeEffect on the bottom of the Window (Or a little bit below the bottom 877 // of the Window if we start to scroll upwards while EdgeEffect is visible). This 878 // does not need to consider the case where this MultiShrinkScroller doesn't fill 879 // the Window, since the nested ScrollView should be set to fillViewport. 880 canvas.translate(-width + getPaddingLeft(), 881 height + getMaximumScrollUpwards() - getScroll()); 882 883 canvas.rotate(180, width, 0); 884 if (mIsTwoPanel) { 885 // Only show the EdgeEffect on the bottom of the ScrollView. 886 mEdgeGlowBottom.setSize(mScrollView.getWidth(), height); 887 if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 888 canvas.translate(mPhotoViewContainer.getWidth(), 0); 889 } 890 } else { 891 mEdgeGlowBottom.setSize(width, height); 892 } 893 if (mEdgeGlowBottom.draw(canvas)) { 894 postInvalidateOnAnimation(); 895 } 896 canvas.restoreToCount(restoreCount); 897 } 898 899 if (!mEdgeGlowTop.isFinished()) { 900 final int restoreCount = canvas.save(); 901 if (mIsTwoPanel) { 902 mEdgeGlowTop.setSize(mScrollView.getWidth(), height); 903 if (getLayoutDirection() != View.LAYOUT_DIRECTION_RTL) { 904 canvas.translate(mPhotoViewContainer.getWidth(), 0); 905 } 906 } else { 907 mEdgeGlowTop.setSize(width, height); 908 } 909 if (mEdgeGlowTop.draw(canvas)) { 910 postInvalidateOnAnimation(); 911 } 912 canvas.restoreToCount(restoreCount); 913 } 914 } 915 916 private float getCurrentVelocity() { 917 if (mVelocityTracker == null) { 918 return 0; 919 } 920 mVelocityTracker.computeCurrentVelocity(PIXELS_PER_SECOND, mMaximumVelocity); 921 return mVelocityTracker.getYVelocity(); 922 } 923 924 private void fling(float velocity) { 925 // For reasons I do not understand, scrolling is less janky when maxY=Integer.MAX_VALUE 926 // then when maxY is set to an actual value. 927 mScroller.fling(0, getScroll(), 0, (int) velocity, 0, 0, -Integer.MAX_VALUE, 928 Integer.MAX_VALUE); 929 if (velocity < 0 && mTransparentView.getHeight() <= 0) { 930 mIsFullscreenDownwardsFling = true; 931 } 932 invalidate(); 933 } 934 935 private int getMaximumScrollUpwards() { 936 if (!mIsTwoPanel) { 937 return mTransparentStartHeight 938 // How much the Header view can compress 939 + getMaximumScrollableHeaderHeight() - getFullyCompressedHeaderHeight() 940 // How much the ScrollView can scroll. 0, if child is smaller than ScrollView. 941 + Math.max(0, mScrollViewChild.getHeight() - getHeight() 942 + getFullyCompressedHeaderHeight()); 943 } else { 944 return mTransparentStartHeight 945 // How much the ScrollView can scroll. 0, if child is smaller than ScrollView. 946 + Math.max(0, mScrollViewChild.getHeight() - getHeight()); 947 } 948 } 949 950 private int getTransparentViewHeight() { 951 return mTransparentView.getLayoutParams().height; 952 } 953 954 private void setTransparentViewHeight(int height) { 955 mTransparentView.getLayoutParams().height = height; 956 mTransparentView.setLayoutParams(mTransparentView.getLayoutParams()); 957 } 958 959 private void scrollUp(int delta) { 960 if (getTransparentViewHeight() != 0) { 961 final int originalValue = getTransparentViewHeight(); 962 setTransparentViewHeight(getTransparentViewHeight() - delta); 963 setTransparentViewHeight(Math.max(0, getTransparentViewHeight())); 964 delta -= originalValue - getTransparentViewHeight(); 965 } 966 final ViewGroup.LayoutParams toolbarLayoutParams 967 = mToolbar.getLayoutParams(); 968 if (toolbarLayoutParams.height > getFullyCompressedHeaderHeight()) { 969 final int originalValue = toolbarLayoutParams.height; 970 toolbarLayoutParams.height -= delta; 971 toolbarLayoutParams.height = Math.max(toolbarLayoutParams.height, 972 getFullyCompressedHeaderHeight()); 973 mToolbar.setLayoutParams(toolbarLayoutParams); 974 delta -= originalValue - toolbarLayoutParams.height; 975 } 976 mScrollView.scrollBy(0, delta); 977 } 978 979 /** 980 * Returns the minimum size that we want to compress the header to, given that we don't want to 981 * allow the the ScrollView to scroll unless there is new content off of the edge of ScrollView. 982 */ 983 private int getFullyCompressedHeaderHeight() { 984 return Math.min(Math.max(mToolbar.getLayoutParams().height - getOverflowingChildViewSize(), 985 mMinimumHeaderHeight), getMaximumScrollableHeaderHeight()); 986 } 987 988 /** 989 * Returns the amount of mScrollViewChild that doesn't fit inside its parent. 990 */ 991 private int getOverflowingChildViewSize() { 992 final int usedScrollViewSpace = mScrollViewChild.getHeight(); 993 return -getHeight() + usedScrollViewSpace + mToolbar.getLayoutParams().height; 994 } 995 996 private void scrollDown(int delta) { 997 if (mScrollView.getScrollY() > 0) { 998 final int originalValue = mScrollView.getScrollY(); 999 mScrollView.scrollBy(0, delta); 1000 delta -= mScrollView.getScrollY() - originalValue; 1001 } 1002 final ViewGroup.LayoutParams toolbarLayoutParams = mToolbar.getLayoutParams(); 1003 if (toolbarLayoutParams.height < getMaximumScrollableHeaderHeight()) { 1004 final int originalValue = toolbarLayoutParams.height; 1005 toolbarLayoutParams.height -= delta; 1006 toolbarLayoutParams.height = Math.min(toolbarLayoutParams.height, 1007 getMaximumScrollableHeaderHeight()); 1008 mToolbar.setLayoutParams(toolbarLayoutParams); 1009 delta -= originalValue - toolbarLayoutParams.height; 1010 } 1011 setTransparentViewHeight(getTransparentViewHeight() - delta); 1012 1013 if (getScrollUntilOffBottom() <= 0) { 1014 post(new Runnable() { 1015 @Override 1016 public void run() { 1017 if (mListener != null) { 1018 mListener.onScrolledOffBottom(); 1019 // No other messages need to be sent to the listener. 1020 mListener = null; 1021 } 1022 } 1023 }); 1024 } 1025 } 1026 1027 /** 1028 * Set the header size and padding, based on the current scroll position. 1029 */ 1030 private void updateHeaderTextSizeAndMargin() { 1031 if (mIsTwoPanel) { 1032 // The text size stays at a constant size & location in two panel layouts. 1033 return; 1034 } 1035 1036 // The pivot point for scaling should be middle of the starting side. 1037 if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 1038 mTitleAndPhoneticNameView.setPivotX(mTitleAndPhoneticNameView.getWidth()); 1039 } else { 1040 mTitleAndPhoneticNameView.setPivotX(0); 1041 } 1042 mTitleAndPhoneticNameView.setPivotY(mTitleAndPhoneticNameView.getHeight() / 2); 1043 1044 final int toolbarHeight = mToolbar.getLayoutParams().height; 1045 mPhotoTouchInterceptOverlay.setClickable(toolbarHeight != mMaximumHeaderHeight); 1046 1047 if (toolbarHeight >= mMaximumHeaderHeight) { 1048 // Everything is full size when the header is fully expanded. 1049 mTitleAndPhoneticNameView.setScaleX(1); 1050 mTitleAndPhoneticNameView.setScaleY(1); 1051 setInterpolatedTitleMargins(1); 1052 return; 1053 } 1054 1055 final float ratio = (toolbarHeight - mMinimumHeaderHeight) 1056 / (float)(mMaximumHeaderHeight - mMinimumHeaderHeight); 1057 final float minimumSize = mInvisiblePlaceholderTextView.getHeight(); 1058 float bezierOutput = mTextSizePathInterpolator.getInterpolation(ratio); 1059 float scale = (minimumSize + (mMaximumHeaderTextSize - minimumSize) * bezierOutput) 1060 / mMaximumHeaderTextSize; 1061 1062 // Clamp to reasonable/finite values before passing into framework. The values 1063 // can be wacky before the first pre-render. 1064 bezierOutput = (float) Math.min(bezierOutput, 1.0f); 1065 scale = (float) Math.min(scale, 1.0f); 1066 1067 mTitleAndPhoneticNameView.setScaleX(scale); 1068 mTitleAndPhoneticNameView.setScaleY(scale); 1069 setInterpolatedTitleMargins(bezierOutput); 1070 } 1071 1072 /** 1073 * Calculate the padding around mTitleAndPhoneticNameView so that it will look appropriate once it 1074 * finishes moving into its target location/size. 1075 */ 1076 private void calculateCollapsedLargeTitlePadding() { 1077 int invisiblePlaceHolderLocation[] = new int[2]; 1078 int largeTextViewRectLocation[] = new int[2]; 1079 mInvisiblePlaceholderTextView.getLocationOnScreen(invisiblePlaceHolderLocation); 1080 mToolbar.getLocationOnScreen(largeTextViewRectLocation); 1081 // Distance between top of toolbar to the center of the target rectangle. 1082 final int desiredTopToCenter = invisiblePlaceHolderLocation[1] 1083 + mInvisiblePlaceholderTextView.getHeight() / 2 1084 - largeTextViewRectLocation[1]; 1085 // Padding needed on the mTitleAndPhoneticNameView so that it has the same amount of 1086 // padding as the target rectangle. 1087 mCollapsedTitleBottomMargin = 1088 desiredTopToCenter - mTitleAndPhoneticNameView.getHeight() / 2; 1089 } 1090 1091 /** 1092 * Interpolate the title's margin size. When {@param x}=1, use the maximum title margins. 1093 * When {@param x}=0, use the margin values taken from {@link #mInvisiblePlaceholderTextView}. 1094 */ 1095 private void setInterpolatedTitleMargins(float x) { 1096 final FrameLayout.LayoutParams titleLayoutParams 1097 = (FrameLayout.LayoutParams) mTitleAndPhoneticNameView.getLayoutParams(); 1098 final LinearLayout.LayoutParams toolbarLayoutParams 1099 = (LinearLayout.LayoutParams) mToolbar.getLayoutParams(); 1100 1101 // Need to add more to margin start if there is a start column 1102 int startColumnWidth = mStartColumn == null ? 0 : mStartColumn.getWidth(); 1103 1104 titleLayoutParams.setMarginStart((int) (mCollapsedTitleStartMargin * (1 - x) 1105 + mMaximumTitleMargin * x) + startColumnWidth); 1106 // How offset the title should be from the bottom of the toolbar 1107 final int pretendBottomMargin = (int) (mCollapsedTitleBottomMargin * (1 - x) 1108 + mMaximumTitleMargin * x) ; 1109 // Calculate how offset the title should be from the top of the screen. Instead of 1110 // calling mTitleAndPhoneticNameView.getHeight() use the mMaximumHeaderTextSize for this 1111 // calculation. The getHeight() value acts unexpectedly when mTitleAndPhoneticNameView is 1112 // partially clipped by its parent. 1113 titleLayoutParams.topMargin = getTransparentViewHeight() 1114 + toolbarLayoutParams.height - pretendBottomMargin 1115 - mMaximumHeaderTextSize; 1116 titleLayoutParams.bottomMargin = 0; 1117 mTitleAndPhoneticNameView.setLayoutParams(titleLayoutParams); 1118 } 1119 1120 private void updatePhotoTintAndDropShadow() { 1121 // Let's keep an eye on how long this method takes to complete. 1122 Trace.beginSection("updatePhotoTintAndDropShadow"); 1123 1124 if (mIsTwoPanel && !mPhotoView.isBasedOffLetterTile()) { 1125 // When in two panel mode, UX considers photo tinting unnecessary for non letter 1126 // tile photos. 1127 mTitleGradientDrawable.setAlpha(0xFF); 1128 mActionBarGradientDrawable.setAlpha(0xFF); 1129 return; 1130 } 1131 1132 // We need to use toolbarLayoutParams to determine the height, since the layout 1133 // params can be updated before the height change is reflected inside the View#getHeight(). 1134 final int toolbarHeight = getToolbarHeight(); 1135 1136 if (toolbarHeight <= mMinimumHeaderHeight && !mIsTwoPanel) { 1137 ViewCompat.setElevation(mPhotoViewContainer, mToolbarElevation); 1138 } else { 1139 ViewCompat.setElevation(mPhotoViewContainer, 0); 1140 } 1141 1142 // Reuse an existing mColorFilter (to avoid GC pauses) to change the photo's tint. 1143 mPhotoView.clearColorFilter(); 1144 mColorMatrix.reset(); 1145 1146 final int gradientAlpha; 1147 if (!mPhotoView.isBasedOffLetterTile()) { 1148 // Constants and equations were arbitrarily picked to choose values for saturation, 1149 // whiteness, tint and gradient alpha. There were four main objectives: 1150 // 1) The transition period between the unmodified image and fully colored image should 1151 // be very short. 1152 // 2) The tinting should be fully applied even before the background image is fully 1153 // faded out and desaturated. Why? A half tinted photo looks bad and results in 1154 // unappealing colors. 1155 // 3) The function should have a derivative of 0 at ratio = 1 to avoid discontinuities. 1156 // 4) The entire process should look awesome. 1157 final float ratio = calculateHeightRatioToBlendingStartHeight(toolbarHeight); 1158 final float alpha = 1.0f - (float) Math.min(Math.pow(ratio, 1.5f) * 2f, 1f); 1159 final float tint = (float) Math.min(Math.pow(ratio, 1.5f) * 3f, 1f); 1160 mColorMatrix.setSaturation(alpha); 1161 mColorMatrix.postConcat(alphaMatrix(alpha, Color.WHITE)); 1162 mColorMatrix.postConcat(multiplyBlendMatrix(mHeaderTintColor, tint)); 1163 gradientAlpha = (int) (255 * alpha); 1164 } else if (mIsTwoPanel) { 1165 mColorMatrix.reset(); 1166 mColorMatrix.postConcat(alphaMatrix(DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA, 1167 mHeaderTintColor)); 1168 gradientAlpha = 0; 1169 } else { 1170 // We want a function that has DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA value 1171 // at the intermediate position and uses TILE_EXPONENT. Finding an equation 1172 // that satisfies this condition requires the following arithmetic. 1173 final float ratio = calculateHeightRatioToFullyOpen(toolbarHeight); 1174 final float intermediateRatio = calculateHeightRatioToFullyOpen((int) 1175 (mMaximumPortraitHeaderHeight * INTERMEDIATE_HEADER_HEIGHT_RATIO)); 1176 final float TILE_EXPONENT = 3f; 1177 final float slowingFactor = (float) ((1 - intermediateRatio) / intermediateRatio 1178 / (1 - Math.pow(1 - DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA, 1/TILE_EXPONENT))); 1179 float linearBeforeIntermediate = Math.max(1 - (1 - ratio) / intermediateRatio 1180 / slowingFactor, 0); 1181 float colorAlpha = 1 - (float) Math.pow(linearBeforeIntermediate, TILE_EXPONENT); 1182 mColorMatrix.postConcat(alphaMatrix(colorAlpha, mHeaderTintColor)); 1183 gradientAlpha = 0; 1184 } 1185 1186 // TODO: remove re-allocation of ColorMatrixColorFilter objects (b/17627000) 1187 mPhotoView.setColorFilter(new ColorMatrixColorFilter(mColorMatrix)); 1188 1189 // Tell the photo view what tint we are trying to achieve. Depending on the type of 1190 // drawable used, the photo view may or may not use this tint. 1191 mPhotoView.setTint(mHeaderTintColor); 1192 mTitleGradientDrawable.setAlpha(gradientAlpha); 1193 mActionBarGradientDrawable.setAlpha(gradientAlpha); 1194 1195 Trace.endSection(); 1196 } 1197 1198 private float calculateHeightRatioToFullyOpen(int height) { 1199 return (height - mMinimumPortraitHeaderHeight) 1200 / (float) (mMaximumPortraitHeaderHeight - mMinimumPortraitHeaderHeight); 1201 } 1202 1203 private float calculateHeightRatioToBlendingStartHeight(int height) { 1204 final float intermediateHeight = mMaximumPortraitHeaderHeight 1205 * COLOR_BLENDING_START_RATIO; 1206 final float interpolatingHeightRange = intermediateHeight - mMinimumPortraitHeaderHeight; 1207 if (height > intermediateHeight) { 1208 return 0; 1209 } 1210 return (intermediateHeight - height) / interpolatingHeightRange; 1211 } 1212 1213 /** 1214 * Simulates alpha blending an image with {@param color}. 1215 */ 1216 private ColorMatrix alphaMatrix(float alpha, int color) { 1217 mAlphaMatrixValues[0] = Color.red(color) * alpha / 255; 1218 mAlphaMatrixValues[6] = Color.green(color) * alpha / 255; 1219 mAlphaMatrixValues[12] = Color.blue(color) * alpha / 255; 1220 mAlphaMatrixValues[4] = 255 * (1 - alpha); 1221 mAlphaMatrixValues[9] = 255 * (1 - alpha); 1222 mAlphaMatrixValues[14] = 255 * (1 - alpha); 1223 mWhitenessColorMatrix.set(mAlphaMatrixValues); 1224 return mWhitenessColorMatrix; 1225 } 1226 1227 /** 1228 * Simulates multiply blending an image with a single {@param color}. 1229 * 1230 * Multiply blending is [Sa * Da, Sc * Dc]. See {@link android.graphics.PorterDuff}. 1231 */ 1232 private ColorMatrix multiplyBlendMatrix(int color, float alpha) { 1233 mMultiplyBlendMatrixValues[0] = multiplyBlend(Color.red(color), alpha); 1234 mMultiplyBlendMatrixValues[6] = multiplyBlend(Color.green(color), alpha); 1235 mMultiplyBlendMatrixValues[12] = multiplyBlend(Color.blue(color), alpha); 1236 mMultiplyBlendMatrix.set(mMultiplyBlendMatrixValues); 1237 return mMultiplyBlendMatrix; 1238 } 1239 1240 private float multiplyBlend(int color, float alpha) { 1241 return color * alpha / 255.0f + (1 - alpha); 1242 } 1243 1244 private void updateLastEventPosition(MotionEvent event) { 1245 mLastEventPosition[0] = event.getX(); 1246 mLastEventPosition[1] = event.getY(); 1247 } 1248 1249 private boolean motionShouldStartDrag(MotionEvent event) { 1250 final float deltaY = event.getY() - mLastEventPosition[1]; 1251 return deltaY > mTouchSlop || deltaY < -mTouchSlop; 1252 } 1253 1254 private float updatePositionAndComputeDelta(MotionEvent event) { 1255 final int VERTICAL = 1; 1256 final float position = mLastEventPosition[VERTICAL]; 1257 updateLastEventPosition(event); 1258 float elasticityFactor = 1; 1259 if (position < mLastEventPosition[VERTICAL] && mHasEverTouchedTheTop) { 1260 // As QuickContacts is dragged from the top of the window, its rate of movement will 1261 // slow down in proportion to its distance from the top. This will feel springy. 1262 elasticityFactor += mTransparentView.getHeight() * SPRING_DAMPENING_FACTOR; 1263 } 1264 return (position - mLastEventPosition[VERTICAL]) / elasticityFactor; 1265 } 1266 1267 private void smoothScrollBy(int delta) { 1268 if (delta == 0) { 1269 // Delta=0 implies the code calling smoothScrollBy is sloppy. We should avoid doing 1270 // this, since it prevents Views from being able to register any clicks for 250ms. 1271 throw new IllegalArgumentException("Smooth scrolling by delta=0 is " 1272 + "pointless and harmful"); 1273 } 1274 mScroller.startScroll(0, getScroll(), 0, delta); 1275 invalidate(); 1276 } 1277 1278 /** 1279 * Interpolator that enforces a specific starting velocity. This is useful to avoid a 1280 * discontinuity between dragging speed and flinging speed. 1281 * 1282 * Similar to a {@link android.view.animation.AccelerateInterpolator} in the sense that 1283 * getInterpolation() is a quadratic function. 1284 */ 1285 private class AcceleratingFlingInterpolator implements Interpolator { 1286 1287 private final float mStartingSpeedPixelsPerFrame; 1288 private final float mDurationMs; 1289 private final int mPixelsDelta; 1290 private final float mNumberFrames; 1291 1292 public AcceleratingFlingInterpolator(int durationMs, float startingSpeedPixelsPerSecond, 1293 int pixelsDelta) { 1294 mStartingSpeedPixelsPerFrame = startingSpeedPixelsPerSecond / getRefreshRate(); 1295 mDurationMs = durationMs; 1296 mPixelsDelta = pixelsDelta; 1297 mNumberFrames = mDurationMs / getFrameIntervalMs(); 1298 } 1299 1300 @Override 1301 public float getInterpolation(float input) { 1302 final float animationIntervalNumber = mNumberFrames * input; 1303 final float linearDelta = (animationIntervalNumber * mStartingSpeedPixelsPerFrame) 1304 / mPixelsDelta; 1305 // Add the results of a linear interpolator (with the initial speed) with the 1306 // results of a AccelerateInterpolator. 1307 if (mStartingSpeedPixelsPerFrame > 0) { 1308 return Math.min(input * input + linearDelta, 1); 1309 } else { 1310 // Initial fling was in the wrong direction, make sure that the quadratic component 1311 // grows faster in order to make up for this. 1312 return Math.min(input * (input - linearDelta) + linearDelta, 1); 1313 } 1314 } 1315 1316 private float getRefreshRate() { 1317 final DisplayManager displayManager = (DisplayManager) MultiShrinkScroller 1318 .this.getContext().getSystemService(Context.DISPLAY_SERVICE); 1319 return displayManager.getDisplay(Display.DEFAULT_DISPLAY).getRefreshRate(); 1320 } 1321 1322 public long getFrameIntervalMs() { 1323 return (long)(1000 / getRefreshRate()); 1324 } 1325 } 1326 1327 /** 1328 * Expand the header if the mScrollViewChild is about to shrink by enough to create new empty 1329 * space at the bottom of this ViewGroup. 1330 */ 1331 public void prepareForShrinkingScrollChild(int heightDelta) { 1332 final int newEmptyScrollViewSpace = -getOverflowingChildViewSize() + heightDelta; 1333 if (newEmptyScrollViewSpace > 0 && !mIsTwoPanel) { 1334 final int newDesiredToolbarHeight = Math.min(getToolbarHeight() 1335 + newEmptyScrollViewSpace, getMaximumScrollableHeaderHeight()); 1336 ObjectAnimator.ofInt(this, "toolbarHeight", newDesiredToolbarHeight).setDuration( 1337 ExpandingEntryCardView.DURATION_COLLAPSE_ANIMATION_CHANGE_BOUNDS).start(); 1338 } 1339 } 1340 1341 /** 1342 * If {@param areTouchesDisabled} is TRUE, ignore all of the user's touches. 1343 */ 1344 public void setDisableTouchesForSuppressLayout(boolean areTouchesDisabled) { 1345 // The card expansion animation uses the Transition framework's ChangeBounds API. This 1346 // invokes suppressLayout(true) on the MultiShrinkScroller. As a result, we need to avoid 1347 // all layout changes during expansion in order to avoid weird layout artifacts. 1348 mIsTouchDisabledForSuppressLayout = areTouchesDisabled; 1349 } 1350 } 1351