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