blob: b1deb901444b14273979632b08d31d3019c95c59 [file] [log] [blame]
package com.android.contacts.widget;
import com.android.contacts.R;
import com.android.contacts.common.compat.CompatUtils;
import com.android.contacts.compat.EdgeEffectCompat;
import com.android.contacts.quickcontact.ExpandingEntryCardView;
import com.android.contacts.test.NeededForReflection;
import com.android.contacts.util.SchedulingUtils;
import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.drawable.GradientDrawable;
import android.hardware.display.DisplayManager;
import android.os.Trace;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.animation.PathInterpolatorCompat;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Display;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewConfiguration;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import android.widget.EdgeEffect;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.Scroller;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toolbar;
/**
* A custom {@link ViewGroup} that operates similarly to a {@link ScrollView}, except with multiple
* subviews. These subviews are scrolled or shrinked one at a time, until each reaches their
* minimum or maximum value.
*
* MultiShrinkScroller is designed for a specific problem. As such, this class is designed to be
* used with a specific layout file: quickcontact_activity.xml. MultiShrinkScroller expects subviews
* with specific ID values.
*
* MultiShrinkScroller's code is heavily influenced by ScrollView. Nonetheless, several ScrollView
* features are missing. For example: handling of KEYCODES, OverScroll bounce and saving
* scroll state in savedInstanceState bundles.
*
* Before copying this approach to nested scrolling, consider whether something simpler & less
* customized will work for you. For example, see the re-usable StickyHeaderListView used by
* WifiSetupActivity (very nice). Alternatively, check out Google+'s cover photo scrolling or
* Android L's built in nested scrolling support. I thought I needed a more custom ViewGroup in
* order to track velocity, modify EdgeEffect color & perform the originally specified animations.
* As a result this ViewGroup has non-standard talkback and keyboard support.
*/
public class MultiShrinkScroller extends FrameLayout {
/**
* 1000 pixels per second. Ie, 1 pixel per millisecond.
*/
private static final int PIXELS_PER_SECOND = 1000;
/**
* Length of the acceleration animations. This value was taken from ValueAnimator.java.
*/
private static final int EXIT_FLING_ANIMATION_DURATION_MS = 250;
/**
* In portrait mode, the height:width ratio of the photo's starting height.
*/
private static final float INTERMEDIATE_HEADER_HEIGHT_RATIO = 0.6f;
/**
* Color blending will only be performed on the contact photo once the toolbar is compressed
* to this ratio of its full height.
*/
private static final float COLOR_BLENDING_START_RATIO = 0.5f;
private static final float SPRING_DAMPENING_FACTOR = 0.01f;
/**
* When displaying a letter tile drawable, this alpha value should be used at the intermediate
* toolbar height.
*/
private static final float DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA = 0.8f;
private float[] mLastEventPosition = { 0, 0 };
private VelocityTracker mVelocityTracker;
private boolean mIsBeingDragged = false;
private boolean mReceivedDown = false;
/**
* Did the current downwards fling/scroll-animation start while we were fullscreen?
*/
private boolean mIsFullscreenDownwardsFling = false;
private ScrollView mScrollView;
private View mScrollViewChild;
private View mToolbar;
private QuickContactImageView mPhotoView;
private View mPhotoViewContainer;
private View mTransparentView;
private MultiShrinkScrollerListener mListener;
private TextView mLargeTextView;
private TextView mPhoneticNameView;
private View mTitleAndPhoneticNameView;
private View mPhotoTouchInterceptOverlay;
/** Contains desired size & vertical offset of the title, once the header is fully compressed */
private TextView mInvisiblePlaceholderTextView;
private View mTitleGradientView;
private View mActionBarGradientView;
private View mStartColumn;
private int mHeaderTintColor;
private int mMaximumHeaderHeight;
private int mMinimumHeaderHeight;
/**
* When the contact photo is tapped, it is resized to max size or this size. This value also
* sometimes represents the maximum achievable header size achieved by scrolling. To enforce
* this maximum in scrolling logic, always access this value via
* {@link #getMaximumScrollableHeaderHeight}.
*/
private int mIntermediateHeaderHeight;
/**
* If true, regular scrolling can expand the header beyond mIntermediateHeaderHeight. The
* header, that contains the contact photo, can expand to a height equal its width.
*/
private boolean mIsOpenContactSquare;
private int mMaximumHeaderTextSize;
private int mMaximumPhoneticNameViewHeight;
private int mMaximumFullNameViewHeight;
private int mCollapsedTitleBottomMargin;
private int mCollapsedTitleStartMargin;
private int mMinimumPortraitHeaderHeight;
private int mMaximumPortraitHeaderHeight;
/**
* True once the header has touched the top of the screen at least once.
*/
private boolean mHasEverTouchedTheTop;
private boolean mIsTouchDisabledForDismissAnimation;
private boolean mIsTouchDisabledForSuppressLayout;
private final Scroller mScroller;
private final EdgeEffect mEdgeGlowBottom;
private final EdgeEffect mEdgeGlowTop;
private final int mTouchSlop;
private final int mMaximumVelocity;
private final int mMinimumVelocity;
private final int mDismissDistanceOnScroll;
private final int mDismissDistanceOnRelease;
private final int mSnapToTopSlopHeight;
private final int mTransparentStartHeight;
private final int mMaximumTitleMargin;
private final float mToolbarElevation;
private final boolean mIsTwoPanel;
private final float mLandscapePhotoRatio;
private final int mActionBarSize;
// Objects used to perform color filtering on the header. These are stored as fields for
// the sole purpose of avoiding "new" operations inside animation loops.
private final ColorMatrix mWhitenessColorMatrix = new ColorMatrix();
private final ColorMatrix mColorMatrix = new ColorMatrix();
private final float[] mAlphaMatrixValues = {
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 1, 0
};
private final ColorMatrix mMultiplyBlendMatrix = new ColorMatrix();
private final float[] mMultiplyBlendMatrixValues = {
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 1, 0
};
private final Interpolator mTextSizePathInterpolator =
PathInterpolatorCompat.create(0.16f, 0.4f, 0.2f, 1);
private final int[] mGradientColors = new int[] {0,0x88000000};
private GradientDrawable mTitleGradientDrawable = new GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM, mGradientColors);
private GradientDrawable mActionBarGradientDrawable = new GradientDrawable(
GradientDrawable.Orientation.BOTTOM_TOP, mGradientColors);
public interface MultiShrinkScrollerListener {
void onScrolledOffBottom();
void onStartScrollOffBottom();
void onTransparentViewHeightChange(float ratio);
void onEntranceAnimationDone();
void onEnterFullscreen();
void onExitFullscreen();
}
private final AnimatorListener mSnapToBottomListener = new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (getScrollUntilOffBottom() > 0 && mListener != null) {
// Due to a rounding error, after the animation finished we haven't fully scrolled
// off the screen. Lie to the listener: tell it that we did scroll off the screen.
mListener.onScrolledOffBottom();
// No other messages need to be sent to the listener.
mListener = null;
}
}
};
/**
* Interpolator from android.support.v4.view.ViewPager. Snappier and more elastic feeling
* than the default interpolator.
*/
private static final Interpolator sInterpolator = new Interpolator() {
/**
* {@inheritDoc}
*/
@Override
public float getInterpolation(float t) {
t -= 1.0f;
return t * t * t * t * t + 1.0f;
}
};
public MultiShrinkScroller(Context context) {
this(context, null);
}
public MultiShrinkScroller(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MultiShrinkScroller(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
final ViewConfiguration configuration = ViewConfiguration.get(context);
setFocusable(false);
// Drawing must be enabled in order to support EdgeEffect
setWillNotDraw(/* willNotDraw = */ false);
mEdgeGlowBottom = new EdgeEffect(context);
mEdgeGlowTop = new EdgeEffect(context);
mScroller = new Scroller(context, sInterpolator);
mTouchSlop = configuration.getScaledTouchSlop();
mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
mTransparentStartHeight = (int) getResources().getDimension(
R.dimen.quickcontact_starting_empty_height);
mToolbarElevation = getResources().getDimension(
R.dimen.quick_contact_toolbar_elevation);
mIsTwoPanel = getResources().getBoolean(R.bool.quickcontact_two_panel);
mMaximumTitleMargin = (int) getResources().getDimension(
R.dimen.quickcontact_title_initial_margin);
mDismissDistanceOnScroll = (int) getResources().getDimension(
R.dimen.quickcontact_dismiss_distance_on_scroll);
mDismissDistanceOnRelease = (int) getResources().getDimension(
R.dimen.quickcontact_dismiss_distance_on_release);
mSnapToTopSlopHeight = (int) getResources().getDimension(
R.dimen.quickcontact_snap_to_top_slop_height);
final TypedValue photoRatio = new TypedValue();
getResources().getValue(R.dimen.quickcontact_landscape_photo_ratio, photoRatio,
/* resolveRefs = */ true);
mLandscapePhotoRatio = photoRatio.getFloat();
final TypedArray attributeArray = context.obtainStyledAttributes(
new int[]{android.R.attr.actionBarSize});
mActionBarSize = attributeArray.getDimensionPixelSize(0, 0);
mMinimumHeaderHeight = mActionBarSize;
// This value is approximately equal to the portrait ActionBar size. It isn't exactly the
// same, since the landscape and portrait ActionBar sizes can be different.
mMinimumPortraitHeaderHeight = mMinimumHeaderHeight;
attributeArray.recycle();
}
/**
* This method must be called inside the Activity's OnCreate.
*/
public void initialize(MultiShrinkScrollerListener listener, boolean isOpenContactSquare,
final int maximumHeaderTextSize, final boolean shouldUpdateNameViewHeight) {
mScrollView = (ScrollView) findViewById(R.id.content_scroller);
mScrollViewChild = findViewById(R.id.card_container);
mToolbar = findViewById(R.id.toolbar_parent);
mPhotoViewContainer = findViewById(R.id.toolbar_parent);
mTransparentView = findViewById(R.id.transparent_view);
mLargeTextView = (TextView) findViewById(R.id.large_title);
mPhoneticNameView = (TextView) findViewById(R.id.phonetic_name);
mTitleAndPhoneticNameView = findViewById(R.id.title_and_phonetic_name);
mInvisiblePlaceholderTextView = (TextView) findViewById(R.id.placeholder_textview);
mStartColumn = findViewById(R.id.empty_start_column);
// Touching the empty space should close the card
if (mStartColumn != null) {
mStartColumn.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
scrollOffBottom();
}
});
findViewById(R.id.empty_end_column).setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
scrollOffBottom();
}
});
}
mListener = listener;
mIsOpenContactSquare = isOpenContactSquare;
mPhotoView = (QuickContactImageView) findViewById(R.id.photo);
mTitleGradientView = findViewById(R.id.title_gradient);
mTitleGradientView.setBackground(mTitleGradientDrawable);
mActionBarGradientView = findViewById(R.id.action_bar_gradient);
mActionBarGradientView.setBackground(mActionBarGradientDrawable);
mCollapsedTitleStartMargin = ((Toolbar) findViewById(R.id.toolbar)).getContentInsetStart();
mPhotoTouchInterceptOverlay = findViewById(R.id.photo_touch_intercept_overlay);
if (!mIsTwoPanel) {
mPhotoTouchInterceptOverlay.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
expandHeader();
}
});
}
SchedulingUtils.doOnPreDraw(this, /* drawNextFrame = */ false, new Runnable() {
@Override
public void run() {
if (!mIsTwoPanel) {
// We never want the height of the photo view to exceed its width.
mMaximumHeaderHeight = mPhotoViewContainer.getWidth();
mIntermediateHeaderHeight = (int) (mMaximumHeaderHeight
* INTERMEDIATE_HEADER_HEIGHT_RATIO);
}
mMaximumPortraitHeaderHeight = mIsTwoPanel ? getHeight()
: mPhotoViewContainer.getWidth();
setHeaderHeight(getMaximumScrollableHeaderHeight());
if (shouldUpdateNameViewHeight) {
mMaximumHeaderTextSize = mTitleAndPhoneticNameView.getHeight();
mMaximumFullNameViewHeight = mLargeTextView.getHeight();
// We cannot rely on mPhoneticNameView.getHeight() since it could be 0
final int phoneticNameSize = getResources().getDimensionPixelSize(
R.dimen.quickcontact_maximum_phonetic_name_size);
final int fullNameSize = getResources().getDimensionPixelSize(
R.dimen.quickcontact_maximum_title_size);
mMaximumPhoneticNameViewHeight =
mMaximumFullNameViewHeight * phoneticNameSize / fullNameSize;
}
if (maximumHeaderTextSize > 0) {
mMaximumHeaderTextSize = maximumHeaderTextSize;
}
if (mIsTwoPanel) {
mMaximumHeaderHeight = getHeight();
mMinimumHeaderHeight = mMaximumHeaderHeight;
mIntermediateHeaderHeight = mMaximumHeaderHeight;
// Permanently set photo width and height.
final ViewGroup.LayoutParams photoLayoutParams
= mPhotoViewContainer.getLayoutParams();
photoLayoutParams.height = mMaximumHeaderHeight;
photoLayoutParams.width = (int) (mMaximumHeaderHeight * mLandscapePhotoRatio);
mPhotoViewContainer.setLayoutParams(photoLayoutParams);
// Permanently set title width and margin.
final FrameLayout.LayoutParams largeTextLayoutParams
= (FrameLayout.LayoutParams) mTitleAndPhoneticNameView
.getLayoutParams();
largeTextLayoutParams.width = photoLayoutParams.width -
largeTextLayoutParams.leftMargin - largeTextLayoutParams.rightMargin;
largeTextLayoutParams.gravity = Gravity.BOTTOM | Gravity.START;
mTitleAndPhoneticNameView.setLayoutParams(largeTextLayoutParams);
} else {
// Set the width of mLargeTextView as if it was nested inside
// mPhotoViewContainer.
mLargeTextView.setWidth(mPhotoViewContainer.getWidth()
- 2 * mMaximumTitleMargin);
mPhoneticNameView.setWidth(mPhotoViewContainer.getWidth()
- 2 * mMaximumTitleMargin);
}
calculateCollapsedLargeTitlePadding();
updateHeaderTextSizeAndMargin();
configureGradientViewHeights();
}
});
}
private void configureGradientViewHeights() {
final FrameLayout.LayoutParams actionBarGradientLayoutParams
= (FrameLayout.LayoutParams) mActionBarGradientView.getLayoutParams();
actionBarGradientLayoutParams.height = mActionBarSize;
mActionBarGradientView.setLayoutParams(actionBarGradientLayoutParams);
final FrameLayout.LayoutParams titleGradientLayoutParams
= (FrameLayout.LayoutParams) mTitleGradientView.getLayoutParams();
final float TITLE_GRADIENT_SIZE_COEFFICIENT = 1.25f;
final FrameLayout.LayoutParams largeTextLayoutParms
= (FrameLayout.LayoutParams) mTitleAndPhoneticNameView.getLayoutParams();
titleGradientLayoutParams.height = (int) ((mMaximumHeaderTextSize
+ largeTextLayoutParms.bottomMargin) * TITLE_GRADIENT_SIZE_COEFFICIENT);
mTitleGradientView.setLayoutParams(titleGradientLayoutParams);
}
public void setTitle(String title, boolean isPhoneNumber) {
mLargeTextView.setText(title);
// We have a phone number as "mLargeTextView" so make it always LTR.
if (isPhoneNumber) {
mLargeTextView.setTextDirection(View.TEXT_DIRECTION_LTR);
}
mPhotoTouchInterceptOverlay.setContentDescription(title);
}
public void setPhoneticName(String phoneticName) {
// Set phonetic name only when it was gone before or got changed.
if (mPhoneticNameView.getVisibility() == View.VISIBLE
&& phoneticName.equals(mPhoneticNameView.getText())) {
return;
}
mPhoneticNameView.setText(phoneticName);
// Every time the phonetic name is changed, set mPhoneticNameView as visible,
// in case it just changed from Visibility=GONE.
mPhoneticNameView.setVisibility(View.VISIBLE);
// TODO try not using initialize() to refresh phonetic name view: b/27410518
initialize(mListener, mIsOpenContactSquare, /* maximumHeaderTextSize */
(mMaximumFullNameViewHeight + mMaximumPhoneticNameViewHeight),
/* shouldUpdateNameViewHeight */ false);
}
public void setPhoneticNameGone() {
// Remove phonetic name only when it was visible before.
if (mPhoneticNameView.getVisibility() == View.GONE) {
return;
}
mPhoneticNameView.setVisibility(View.GONE);
// Initialize to make Visibility work.
// TODO try not using initialize() to refresh phonetic name view: b/27410518
initialize(mListener, mIsOpenContactSquare,
/* maximumHeaderTextSize */ mMaximumFullNameViewHeight,
/* shouldUpdateNameViewHeight */ false);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
// The only time we want to intercept touch events is when we are being dragged.
return shouldStartDrag(event);
}
private boolean shouldStartDrag(MotionEvent event) {
if (mIsTouchDisabledForDismissAnimation || mIsTouchDisabledForSuppressLayout) return false;
if (mIsBeingDragged) {
mIsBeingDragged = false;
return false;
}
switch (event.getAction()) {
// If we are in the middle of a fling and there is a down event, we'll steal it and
// start a drag.
case MotionEvent.ACTION_DOWN:
updateLastEventPosition(event);
if (!mScroller.isFinished()) {
startDrag();
return true;
} else {
mReceivedDown = true;
}
break;
// Otherwise, we will start a drag if there is enough motion in the direction we are
// capable of scrolling.
case MotionEvent.ACTION_MOVE:
if (motionShouldStartDrag(event)) {
updateLastEventPosition(event);
startDrag();
return true;
}
break;
}
return false;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mIsTouchDisabledForDismissAnimation || mIsTouchDisabledForSuppressLayout) return true;
final int action = event.getAction();
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
if (!mIsBeingDragged) {
if (shouldStartDrag(event)) {
return true;
}
if (action == MotionEvent.ACTION_UP && mReceivedDown) {
mReceivedDown = false;
return performClick();
}
return true;
}
switch (action) {
case MotionEvent.ACTION_MOVE:
final float delta = updatePositionAndComputeDelta(event);
scrollTo(0, getScroll() + (int) delta);
mReceivedDown = false;
if (mIsBeingDragged) {
final int distanceFromMaxScrolling = getMaximumScrollUpwards() - getScroll();
if (delta > distanceFromMaxScrolling) {
// The ScrollView is being pulled upwards while there is no more
// content offscreen, and the view port is already fully expanded.
EdgeEffectCompat.onPull(mEdgeGlowBottom, delta / getHeight(),
1 - event.getX() / getWidth());
}
if (!mEdgeGlowBottom.isFinished()) {
postInvalidateOnAnimation();
}
if (shouldDismissOnScroll()) {
scrollOffBottom();
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
stopDrag(action == MotionEvent.ACTION_CANCEL);
mReceivedDown = false;
break;
}
return true;
}
public void setHeaderTintColor(int color) {
mHeaderTintColor = color;
updatePhotoTintAndDropShadow();
if (CompatUtils.isLollipopCompatible()) {
// Use the same amount of alpha on the new tint color as the previous tint color.
final int edgeEffectAlpha = Color.alpha(mEdgeGlowBottom.getColor());
mEdgeGlowBottom.setColor((color & 0xffffff) | Color.argb(edgeEffectAlpha, 0, 0, 0));
mEdgeGlowTop.setColor(mEdgeGlowBottom.getColor());
}
}
/**
* Expand to maximum size.
*/
private void expandHeader() {
if (getHeaderHeight() != mMaximumHeaderHeight) {
final ObjectAnimator animator = ObjectAnimator.ofInt(this, "headerHeight",
mMaximumHeaderHeight);
animator.setDuration(ExpandingEntryCardView.DURATION_EXPAND_ANIMATION_CHANGE_BOUNDS);
animator.start();
// Scroll nested scroll view to its top
if (mScrollView.getScrollY() != 0) {
ObjectAnimator.ofInt(mScrollView, "scrollY", -mScrollView.getScrollY()).start();
}
}
}
private void startDrag() {
mIsBeingDragged = true;
mScroller.abortAnimation();
}
private void stopDrag(boolean cancelled) {
mIsBeingDragged = false;
if (!cancelled && getChildCount() > 0) {
final float velocity = getCurrentVelocity();
if (velocity > mMinimumVelocity || velocity < -mMinimumVelocity) {
fling(-velocity);
onDragFinished(mScroller.getFinalY() - mScroller.getStartY());
} else {
onDragFinished(/* flingDelta = */ 0);
}
} else {
onDragFinished(/* flingDelta = */ 0);
}
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
mEdgeGlowBottom.onRelease();
}
private void onDragFinished(int flingDelta) {
if (getTransparentViewHeight() <= 0) {
// Don't perform any snapping if quick contacts is full screen.
return;
}
if (!snapToTopOnDragFinished(flingDelta)) {
// The drag/fling won't result in the content at the top of the Window. Consider
// snapping the content to the bottom of the window.
snapToBottomOnDragFinished();
}
}
/**
* If needed, snap the subviews to the top of the Window.
*
* @return TRUE if QuickContacts will snap/fling to to top after this method call.
*/
private boolean snapToTopOnDragFinished(int flingDelta) {
if (!mHasEverTouchedTheTop) {
// If the current fling is predicted to scroll past the top, then we don't need to snap
// to the top. However, if the fling only flings past the top by a tiny amount,
// it will look nicer to snap than to fling.
final float predictedScrollPastTop = getTransparentViewHeight() - flingDelta;
if (predictedScrollPastTop < -mSnapToTopSlopHeight) {
return false;
}
if (getTransparentViewHeight() <= mTransparentStartHeight) {
// We are above the starting scroll position so snap to the top.
mScroller.forceFinished(true);
smoothScrollBy(getTransparentViewHeight());
return true;
}
return false;
}
if (getTransparentViewHeight() < mDismissDistanceOnRelease) {
mScroller.forceFinished(true);
smoothScrollBy(getTransparentViewHeight());
return true;
}
return false;
}
/**
* If needed, scroll all the subviews off the bottom of the Window.
*/
private void snapToBottomOnDragFinished() {
if (mHasEverTouchedTheTop) {
if (getTransparentViewHeight() > mDismissDistanceOnRelease) {
scrollOffBottom();
}
return;
}
if (getTransparentViewHeight() > mTransparentStartHeight) {
scrollOffBottom();
}
}
/**
* Returns TRUE if we have scrolled far QuickContacts far enough that we should dismiss it
* without waiting for the user to finish their drag.
*/
private boolean shouldDismissOnScroll() {
return mHasEverTouchedTheTop && getTransparentViewHeight() > mDismissDistanceOnScroll;
}
/**
* Return ratio of non-transparent:viewgroup-height for this viewgroup at the starting position.
*/
public float getStartingTransparentHeightRatio() {
return getTransparentHeightRatio(mTransparentStartHeight);
}
private float getTransparentHeightRatio(int transparentHeight) {
final float heightRatio = (float) transparentHeight / getHeight();
// Clamp between [0, 1] in case this is called before height is initialized.
return 1.0f - Math.max(Math.min(1.0f, heightRatio), 0f);
}
public void scrollOffBottom() {
mIsTouchDisabledForDismissAnimation = true;
final Interpolator interpolator = new AcceleratingFlingInterpolator(
EXIT_FLING_ANIMATION_DURATION_MS, getCurrentVelocity(),
getScrollUntilOffBottom());
mScroller.forceFinished(true);
ObjectAnimator translateAnimation = ObjectAnimator.ofInt(this, "scroll",
getScroll() - getScrollUntilOffBottom());
translateAnimation.setRepeatCount(0);
translateAnimation.setInterpolator(interpolator);
translateAnimation.setDuration(EXIT_FLING_ANIMATION_DURATION_MS);
translateAnimation.addListener(mSnapToBottomListener);
translateAnimation.start();
if (mListener != null) {
mListener.onStartScrollOffBottom();
}
}
/**
* @param scrollToCurrentPosition if true, will scroll from the bottom of the screen to the
* current position. Otherwise, will scroll from the bottom of the screen to the top of the
* screen.
*/
public void scrollUpForEntranceAnimation(boolean scrollToCurrentPosition) {
final int currentPosition = getScroll();
final int bottomScrollPosition = currentPosition
- (getHeight() - getTransparentViewHeight()) + 1;
final Interpolator interpolator = AnimationUtils.loadInterpolator(getContext(),
android.R.interpolator.linear_out_slow_in);
final int desiredValue = currentPosition + (scrollToCurrentPosition ? currentPosition
: getTransparentViewHeight());
final ObjectAnimator animator = ObjectAnimator.ofInt(this, "scroll", bottomScrollPosition,
desiredValue);
animator.setInterpolator(interpolator);
animator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
if (animation.getAnimatedValue().equals(desiredValue) && mListener != null) {
mListener.onEntranceAnimationDone();
}
}
});
animator.start();
}
@Override
public void scrollTo(int x, int y) {
final int delta = y - getScroll();
boolean wasFullscreen = getScrollNeededToBeFullScreen() <= 0;
if (delta > 0) {
scrollUp(delta);
} else {
scrollDown(delta);
}
updatePhotoTintAndDropShadow();
updateHeaderTextSizeAndMargin();
final boolean isFullscreen = getScrollNeededToBeFullScreen() <= 0;
mHasEverTouchedTheTop |= isFullscreen;
if (mListener != null) {
if (wasFullscreen && !isFullscreen) {
mListener.onExitFullscreen();
} else if (!wasFullscreen && isFullscreen) {
mListener.onEnterFullscreen();
}
if (!isFullscreen || !wasFullscreen) {
mListener.onTransparentViewHeightChange(
getTransparentHeightRatio(getTransparentViewHeight()));
}
}
}
/**
* Change the height of the header/toolbar. Do *not* use this outside animations. This was
* designed for use by {@link #prepareForShrinkingScrollChild}.
*/
@NeededForReflection
public void setToolbarHeight(int delta) {
final ViewGroup.LayoutParams toolbarLayoutParams
= mToolbar.getLayoutParams();
toolbarLayoutParams.height = delta;
mToolbar.setLayoutParams(toolbarLayoutParams);
updatePhotoTintAndDropShadow();
updateHeaderTextSizeAndMargin();
}
@NeededForReflection
public int getToolbarHeight() {
return mToolbar.getLayoutParams().height;
}
/**
* Set the height of the toolbar and update its tint accordingly.
*/
@NeededForReflection
public void setHeaderHeight(int height) {
final ViewGroup.LayoutParams toolbarLayoutParams
= mToolbar.getLayoutParams();
toolbarLayoutParams.height = height;
mToolbar.setLayoutParams(toolbarLayoutParams);
updatePhotoTintAndDropShadow();
updateHeaderTextSizeAndMargin();
}
@NeededForReflection
public int getHeaderHeight() {
return mToolbar.getLayoutParams().height;
}
@NeededForReflection
public void setScroll(int scroll) {
scrollTo(0, scroll);
}
/**
* Returns the total amount scrolled inside the nested ScrollView + the amount of shrinking
* performed on the ToolBar. This is the value inspected by animators.
*/
@NeededForReflection
public int getScroll() {
return mTransparentStartHeight - getTransparentViewHeight()
+ getMaximumScrollableHeaderHeight() - getToolbarHeight()
+ mScrollView.getScrollY();
}
private int getMaximumScrollableHeaderHeight() {
return mIsOpenContactSquare ? mMaximumHeaderHeight : mIntermediateHeaderHeight;
}
/**
* A variant of {@link #getScroll} that pretends the header is never larger than
* than mIntermediateHeaderHeight. This function is sometimes needed when making scrolling
* decisions that will not change the header size (ie, snapping to the bottom or top).
*
* When mIsOpenContactSquare is true, this function considers mIntermediateHeaderHeight ==
* mMaximumHeaderHeight, since snapping decisions will be made relative the full header
* size when mIsOpenContactSquare = true.
*
* This value should never be used in conjunction with {@link #getScroll} values.
*/
private int getScroll_ignoreOversizedHeaderForSnapping() {
return mTransparentStartHeight - getTransparentViewHeight()
+ Math.max(getMaximumScrollableHeaderHeight() - getToolbarHeight(), 0)
+ mScrollView.getScrollY();
}
/**
* Amount of transparent space above the header/toolbar.
*/
public int getScrollNeededToBeFullScreen() {
return getTransparentViewHeight();
}
/**
* Return amount of scrolling needed in order for all the visible subviews to scroll off the
* bottom.
*/
private int getScrollUntilOffBottom() {
return getHeight() + getScroll_ignoreOversizedHeaderForSnapping()
- mTransparentStartHeight;
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
// Examine the fling results in order to activate EdgeEffect and halt flings.
final int oldScroll = getScroll();
scrollTo(0, mScroller.getCurrY());
final int delta = mScroller.getCurrY() - oldScroll;
final int distanceFromMaxScrolling = getMaximumScrollUpwards() - getScroll();
if (delta > distanceFromMaxScrolling && distanceFromMaxScrolling > 0) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
if (mIsFullscreenDownwardsFling && getTransparentViewHeight() > 0) {
// Halt the fling once QuickContact's top is on screen.
scrollTo(0, getScroll() + getTransparentViewHeight());
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
mScroller.abortAnimation();
mIsFullscreenDownwardsFling = false;
}
if (!awakenScrollBars()) {
// Keep on drawing until the animation has finished.
postInvalidateOnAnimation();
}
if (mScroller.getCurrY() >= getMaximumScrollUpwards()) {
// Halt the fling once QuickContact's bottom is on screen.
mScroller.abortAnimation();
mIsFullscreenDownwardsFling = false;
}
}
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
final int width = getWidth() - getPaddingLeft() - getPaddingRight();
final int height = getHeight();
if (!mEdgeGlowBottom.isFinished()) {
final int restoreCount = canvas.save();
// Draw the EdgeEffect on the bottom of the Window (Or a little bit below the bottom
// of the Window if we start to scroll upwards while EdgeEffect is visible). This
// does not need to consider the case where this MultiShrinkScroller doesn't fill
// the Window, since the nested ScrollView should be set to fillViewport.
canvas.translate(-width + getPaddingLeft(),
height + getMaximumScrollUpwards() - getScroll());
canvas.rotate(180, width, 0);
if (mIsTwoPanel) {
// Only show the EdgeEffect on the bottom of the ScrollView.
mEdgeGlowBottom.setSize(mScrollView.getWidth(), height);
if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
canvas.translate(mPhotoViewContainer.getWidth(), 0);
}
} else {
mEdgeGlowBottom.setSize(width, height);
}
if (mEdgeGlowBottom.draw(canvas)) {
postInvalidateOnAnimation();
}
canvas.restoreToCount(restoreCount);
}
if (!mEdgeGlowTop.isFinished()) {
final int restoreCount = canvas.save();
if (mIsTwoPanel) {
mEdgeGlowTop.setSize(mScrollView.getWidth(), height);
if (getLayoutDirection() != View.LAYOUT_DIRECTION_RTL) {
canvas.translate(mPhotoViewContainer.getWidth(), 0);
}
} else {
mEdgeGlowTop.setSize(width, height);
}
if (mEdgeGlowTop.draw(canvas)) {
postInvalidateOnAnimation();
}
canvas.restoreToCount(restoreCount);
}
}
private float getCurrentVelocity() {
if (mVelocityTracker == null) {
return 0;
}
mVelocityTracker.computeCurrentVelocity(PIXELS_PER_SECOND, mMaximumVelocity);
return mVelocityTracker.getYVelocity();
}
private void fling(float velocity) {
// For reasons I do not understand, scrolling is less janky when maxY=Integer.MAX_VALUE
// then when maxY is set to an actual value.
mScroller.fling(0, getScroll(), 0, (int) velocity, 0, 0, -Integer.MAX_VALUE,
Integer.MAX_VALUE);
if (velocity < 0 && mTransparentView.getHeight() <= 0) {
mIsFullscreenDownwardsFling = true;
}
invalidate();
}
private int getMaximumScrollUpwards() {
if (!mIsTwoPanel) {
return mTransparentStartHeight
// How much the Header view can compress
+ getMaximumScrollableHeaderHeight() - getFullyCompressedHeaderHeight()
// How much the ScrollView can scroll. 0, if child is smaller than ScrollView.
+ Math.max(0, mScrollViewChild.getHeight() - getHeight()
+ getFullyCompressedHeaderHeight());
} else {
return mTransparentStartHeight
// How much the ScrollView can scroll. 0, if child is smaller than ScrollView.
+ Math.max(0, mScrollViewChild.getHeight() - getHeight());
}
}
private int getTransparentViewHeight() {
return mTransparentView.getLayoutParams().height;
}
private void setTransparentViewHeight(int height) {
mTransparentView.getLayoutParams().height = height;
mTransparentView.setLayoutParams(mTransparentView.getLayoutParams());
}
private void scrollUp(int delta) {
if (getTransparentViewHeight() != 0) {
final int originalValue = getTransparentViewHeight();
setTransparentViewHeight(getTransparentViewHeight() - delta);
setTransparentViewHeight(Math.max(0, getTransparentViewHeight()));
delta -= originalValue - getTransparentViewHeight();
}
final ViewGroup.LayoutParams toolbarLayoutParams
= mToolbar.getLayoutParams();
if (toolbarLayoutParams.height > getFullyCompressedHeaderHeight()) {
final int originalValue = toolbarLayoutParams.height;
toolbarLayoutParams.height -= delta;
toolbarLayoutParams.height = Math.max(toolbarLayoutParams.height,
getFullyCompressedHeaderHeight());
mToolbar.setLayoutParams(toolbarLayoutParams);
delta -= originalValue - toolbarLayoutParams.height;
}
mScrollView.scrollBy(0, delta);
}
/**
* Returns the minimum size that we want to compress the header to, given that we don't want to
* allow the the ScrollView to scroll unless there is new content off of the edge of ScrollView.
*/
private int getFullyCompressedHeaderHeight() {
return Math.min(Math.max(mToolbar.getLayoutParams().height - getOverflowingChildViewSize(),
mMinimumHeaderHeight), getMaximumScrollableHeaderHeight());
}
/**
* Returns the amount of mScrollViewChild that doesn't fit inside its parent.
*/
private int getOverflowingChildViewSize() {
final int usedScrollViewSpace = mScrollViewChild.getHeight();
return -getHeight() + usedScrollViewSpace + mToolbar.getLayoutParams().height;
}
private void scrollDown(int delta) {
if (mScrollView.getScrollY() > 0) {
final int originalValue = mScrollView.getScrollY();
mScrollView.scrollBy(0, delta);
delta -= mScrollView.getScrollY() - originalValue;
}
final ViewGroup.LayoutParams toolbarLayoutParams = mToolbar.getLayoutParams();
if (toolbarLayoutParams.height < getMaximumScrollableHeaderHeight()) {
final int originalValue = toolbarLayoutParams.height;
toolbarLayoutParams.height -= delta;
toolbarLayoutParams.height = Math.min(toolbarLayoutParams.height,
getMaximumScrollableHeaderHeight());
mToolbar.setLayoutParams(toolbarLayoutParams);
delta -= originalValue - toolbarLayoutParams.height;
}
setTransparentViewHeight(getTransparentViewHeight() - delta);
if (getScrollUntilOffBottom() <= 0) {
post(new Runnable() {
@Override
public void run() {
if (mListener != null) {
mListener.onScrolledOffBottom();
// No other messages need to be sent to the listener.
mListener = null;
}
}
});
}
}
/**
* Set the header size and padding, based on the current scroll position.
*/
private void updateHeaderTextSizeAndMargin() {
if (mIsTwoPanel) {
// The text size stays at a constant size & location in two panel layouts.
return;
}
// The pivot point for scaling should be middle of the starting side.
if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
mTitleAndPhoneticNameView.setPivotX(mTitleAndPhoneticNameView.getWidth());
} else {
mTitleAndPhoneticNameView.setPivotX(0);
}
mTitleAndPhoneticNameView.setPivotY(mMaximumHeaderTextSize / 2);
final int toolbarHeight = mToolbar.getLayoutParams().height;
mPhotoTouchInterceptOverlay.setClickable(toolbarHeight != mMaximumHeaderHeight);
if (toolbarHeight >= mMaximumHeaderHeight) {
// Everything is full size when the header is fully expanded.
mTitleAndPhoneticNameView.setScaleX(1);
mTitleAndPhoneticNameView.setScaleY(1);
setInterpolatedTitleMargins(1);
return;
}
final float ratio = (toolbarHeight - mMinimumHeaderHeight)
/ (float)(mMaximumHeaderHeight - mMinimumHeaderHeight);
final float minimumSize = mInvisiblePlaceholderTextView.getHeight();
float bezierOutput = mTextSizePathInterpolator.getInterpolation(ratio);
float scale = (minimumSize + (mMaximumHeaderTextSize - minimumSize) * bezierOutput)
/ mMaximumHeaderTextSize;
// Clamp to reasonable/finite values before passing into framework. The values
// can be wacky before the first pre-render.
bezierOutput = (float) Math.min(bezierOutput, 1.0f);
scale = (float) Math.min(scale, 1.0f);
mTitleAndPhoneticNameView.setScaleX(scale);
mTitleAndPhoneticNameView.setScaleY(scale);
setInterpolatedTitleMargins(bezierOutput);
}
/**
* Calculate the padding around mTitleAndPhoneticNameView so that it will look appropriate once it
* finishes moving into its target location/size.
*/
private void calculateCollapsedLargeTitlePadding() {
int invisiblePlaceHolderLocation[] = new int[2];
int largeTextViewRectLocation[] = new int[2];
mInvisiblePlaceholderTextView.getLocationOnScreen(invisiblePlaceHolderLocation);
mToolbar.getLocationOnScreen(largeTextViewRectLocation);
// Distance between top of toolbar to the center of the target rectangle.
final int desiredTopToCenter = invisiblePlaceHolderLocation[1]
+ mInvisiblePlaceholderTextView.getHeight() / 2
- largeTextViewRectLocation[1];
// Padding needed on the mTitleAndPhoneticNameView so that it has the same amount of
// padding as the target rectangle.
mCollapsedTitleBottomMargin =
desiredTopToCenter - mMaximumHeaderTextSize / 2;
}
/**
* Interpolate the title's margin size. When {@param x}=1, use the maximum title margins.
* When {@param x}=0, use the margin values taken from {@link #mInvisiblePlaceholderTextView}.
*/
private void setInterpolatedTitleMargins(float x) {
final FrameLayout.LayoutParams titleLayoutParams
= (FrameLayout.LayoutParams) mTitleAndPhoneticNameView.getLayoutParams();
final LinearLayout.LayoutParams toolbarLayoutParams
= (LinearLayout.LayoutParams) mToolbar.getLayoutParams();
// Need to add more to margin start if there is a start column
int startColumnWidth = mStartColumn == null ? 0 : mStartColumn.getWidth();
titleLayoutParams.setMarginStart((int) (mCollapsedTitleStartMargin * (1 - x)
+ mMaximumTitleMargin * x) + startColumnWidth);
// How offset the title should be from the bottom of the toolbar
final int pretendBottomMargin = (int) (mCollapsedTitleBottomMargin * (1 - x)
+ mMaximumTitleMargin * x) ;
// Calculate how offset the title should be from the top of the screen. Instead of
// calling mTitleAndPhoneticNameView.getHeight() use the mMaximumHeaderTextSize for this
// calculation. The getHeight() value acts unexpectedly when mTitleAndPhoneticNameView is
// partially clipped by its parent.
titleLayoutParams.topMargin = getTransparentViewHeight()
+ toolbarLayoutParams.height - pretendBottomMargin
- mMaximumHeaderTextSize;
titleLayoutParams.bottomMargin = 0;
mTitleAndPhoneticNameView.setLayoutParams(titleLayoutParams);
}
private void updatePhotoTintAndDropShadow() {
// Let's keep an eye on how long this method takes to complete.
Trace.beginSection("updatePhotoTintAndDropShadow");
if (mIsTwoPanel && !mPhotoView.isBasedOffLetterTile()) {
// When in two panel mode, UX considers photo tinting unnecessary for non letter
// tile photos.
mTitleGradientDrawable.setAlpha(0xFF);
mActionBarGradientDrawable.setAlpha(0xFF);
return;
}
// We need to use toolbarLayoutParams to determine the height, since the layout
// params can be updated before the height change is reflected inside the View#getHeight().
final int toolbarHeight = getToolbarHeight();
if (toolbarHeight <= mMinimumHeaderHeight && !mIsTwoPanel) {
ViewCompat.setElevation(mPhotoViewContainer, mToolbarElevation);
} else {
ViewCompat.setElevation(mPhotoViewContainer, 0);
}
// Reuse an existing mColorFilter (to avoid GC pauses) to change the photo's tint.
mPhotoView.clearColorFilter();
mColorMatrix.reset();
final int gradientAlpha;
if (!mPhotoView.isBasedOffLetterTile()) {
// Constants and equations were arbitrarily picked to choose values for saturation,
// whiteness, tint and gradient alpha. There were four main objectives:
// 1) The transition period between the unmodified image and fully colored image should
// be very short.
// 2) The tinting should be fully applied even before the background image is fully
// faded out and desaturated. Why? A half tinted photo looks bad and results in
// unappealing colors.
// 3) The function should have a derivative of 0 at ratio = 1 to avoid discontinuities.
// 4) The entire process should look awesome.
final float ratio = calculateHeightRatioToBlendingStartHeight(toolbarHeight);
final float alpha = 1.0f - (float) Math.min(Math.pow(ratio, 1.5f) * 2f, 1f);
final float tint = (float) Math.min(Math.pow(ratio, 1.5f) * 3f, 1f);
mColorMatrix.setSaturation(alpha);
mColorMatrix.postConcat(alphaMatrix(alpha, Color.WHITE));
mColorMatrix.postConcat(multiplyBlendMatrix(mHeaderTintColor, tint));
gradientAlpha = (int) (255 * alpha);
} else if (mIsTwoPanel) {
mColorMatrix.reset();
mColorMatrix.postConcat(alphaMatrix(DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA,
mHeaderTintColor));
gradientAlpha = 0;
} else {
// We want a function that has DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA value
// at the intermediate position and uses TILE_EXPONENT. Finding an equation
// that satisfies this condition requires the following arithmetic.
final float ratio = calculateHeightRatioToFullyOpen(toolbarHeight);
final float intermediateRatio = calculateHeightRatioToFullyOpen((int)
(mMaximumPortraitHeaderHeight * INTERMEDIATE_HEADER_HEIGHT_RATIO));
final float TILE_EXPONENT = 3f;
final float slowingFactor = (float) ((1 - intermediateRatio) / intermediateRatio
/ (1 - Math.pow(1 - DESIRED_INTERMEDIATE_LETTER_TILE_ALPHA, 1/TILE_EXPONENT)));
float linearBeforeIntermediate = Math.max(1 - (1 - ratio) / intermediateRatio
/ slowingFactor, 0);
float colorAlpha = 1 - (float) Math.pow(linearBeforeIntermediate, TILE_EXPONENT);
mColorMatrix.postConcat(alphaMatrix(colorAlpha, mHeaderTintColor));
gradientAlpha = 0;
}
// TODO: remove re-allocation of ColorMatrixColorFilter objects (b/17627000)
mPhotoView.setColorFilter(new ColorMatrixColorFilter(mColorMatrix));
// Tell the photo view what tint we are trying to achieve. Depending on the type of
// drawable used, the photo view may or may not use this tint.
mPhotoView.setTint(mHeaderTintColor);
mTitleGradientDrawable.setAlpha(gradientAlpha);
mActionBarGradientDrawable.setAlpha(gradientAlpha);
Trace.endSection();
}
private float calculateHeightRatioToFullyOpen(int height) {
return (height - mMinimumPortraitHeaderHeight)
/ (float) (mMaximumPortraitHeaderHeight - mMinimumPortraitHeaderHeight);
}
private float calculateHeightRatioToBlendingStartHeight(int height) {
final float intermediateHeight = mMaximumPortraitHeaderHeight
* COLOR_BLENDING_START_RATIO;
final float interpolatingHeightRange = intermediateHeight - mMinimumPortraitHeaderHeight;
if (height > intermediateHeight) {
return 0;
}
return (intermediateHeight - height) / interpolatingHeightRange;
}
/**
* Simulates alpha blending an image with {@param color}.
*/
private ColorMatrix alphaMatrix(float alpha, int color) {
mAlphaMatrixValues[0] = Color.red(color) * alpha / 255;
mAlphaMatrixValues[6] = Color.green(color) * alpha / 255;
mAlphaMatrixValues[12] = Color.blue(color) * alpha / 255;
mAlphaMatrixValues[4] = 255 * (1 - alpha);
mAlphaMatrixValues[9] = 255 * (1 - alpha);
mAlphaMatrixValues[14] = 255 * (1 - alpha);
mWhitenessColorMatrix.set(mAlphaMatrixValues);
return mWhitenessColorMatrix;
}
/**
* Simulates multiply blending an image with a single {@param color}.
*
* Multiply blending is [Sa * Da, Sc * Dc]. See {@link android.graphics.PorterDuff}.
*/
private ColorMatrix multiplyBlendMatrix(int color, float alpha) {
mMultiplyBlendMatrixValues[0] = multiplyBlend(Color.red(color), alpha);
mMultiplyBlendMatrixValues[6] = multiplyBlend(Color.green(color), alpha);
mMultiplyBlendMatrixValues[12] = multiplyBlend(Color.blue(color), alpha);
mMultiplyBlendMatrix.set(mMultiplyBlendMatrixValues);
return mMultiplyBlendMatrix;
}
private float multiplyBlend(int color, float alpha) {
return color * alpha / 255.0f + (1 - alpha);
}
private void updateLastEventPosition(MotionEvent event) {
mLastEventPosition[0] = event.getX();
mLastEventPosition[1] = event.getY();
}
private boolean motionShouldStartDrag(MotionEvent event) {
final float deltaY = event.getY() - mLastEventPosition[1];
return deltaY > mTouchSlop || deltaY < -mTouchSlop;
}
private float updatePositionAndComputeDelta(MotionEvent event) {
final int VERTICAL = 1;
final float position = mLastEventPosition[VERTICAL];
updateLastEventPosition(event);
float elasticityFactor = 1;
if (position < mLastEventPosition[VERTICAL] && mHasEverTouchedTheTop) {
// As QuickContacts is dragged from the top of the window, its rate of movement will
// slow down in proportion to its distance from the top. This will feel springy.
elasticityFactor += mTransparentView.getHeight() * SPRING_DAMPENING_FACTOR;
}
return (position - mLastEventPosition[VERTICAL]) / elasticityFactor;
}
private void smoothScrollBy(int delta) {
if (delta == 0) {
// Delta=0 implies the code calling smoothScrollBy is sloppy. We should avoid doing
// this, since it prevents Views from being able to register any clicks for 250ms.
throw new IllegalArgumentException("Smooth scrolling by delta=0 is "
+ "pointless and harmful");
}
mScroller.startScroll(0, getScroll(), 0, delta);
invalidate();
}
/**
* Interpolator that enforces a specific starting velocity. This is useful to avoid a
* discontinuity between dragging speed and flinging speed.
*
* Similar to a {@link android.view.animation.AccelerateInterpolator} in the sense that
* getInterpolation() is a quadratic function.
*/
private class AcceleratingFlingInterpolator implements Interpolator {
private final float mStartingSpeedPixelsPerFrame;
private final float mDurationMs;
private final int mPixelsDelta;
private final float mNumberFrames;
public AcceleratingFlingInterpolator(int durationMs, float startingSpeedPixelsPerSecond,
int pixelsDelta) {
mStartingSpeedPixelsPerFrame = startingSpeedPixelsPerSecond / getRefreshRate();
mDurationMs = durationMs;
mPixelsDelta = pixelsDelta;
mNumberFrames = mDurationMs / getFrameIntervalMs();
}
@Override
public float getInterpolation(float input) {
final float animationIntervalNumber = mNumberFrames * input;
final float linearDelta = (animationIntervalNumber * mStartingSpeedPixelsPerFrame)
/ mPixelsDelta;
// Add the results of a linear interpolator (with the initial speed) with the
// results of a AccelerateInterpolator.
if (mStartingSpeedPixelsPerFrame > 0) {
return Math.min(input * input + linearDelta, 1);
} else {
// Initial fling was in the wrong direction, make sure that the quadratic component
// grows faster in order to make up for this.
return Math.min(input * (input - linearDelta) + linearDelta, 1);
}
}
private float getRefreshRate() {
final DisplayManager displayManager = (DisplayManager) MultiShrinkScroller
.this.getContext().getSystemService(Context.DISPLAY_SERVICE);
return displayManager.getDisplay(Display.DEFAULT_DISPLAY).getRefreshRate();
}
public long getFrameIntervalMs() {
return (long)(1000 / getRefreshRate());
}
}
/**
* Expand the header if the mScrollViewChild is about to shrink by enough to create new empty
* space at the bottom of this ViewGroup.
*/
public void prepareForShrinkingScrollChild(int heightDelta) {
final int newEmptyScrollViewSpace = -getOverflowingChildViewSize() + heightDelta;
if (newEmptyScrollViewSpace > 0 && !mIsTwoPanel) {
final int newDesiredToolbarHeight = Math.min(getToolbarHeight()
+ newEmptyScrollViewSpace, getMaximumScrollableHeaderHeight());
ObjectAnimator.ofInt(this, "toolbarHeight", newDesiredToolbarHeight).setDuration(
ExpandingEntryCardView.DURATION_COLLAPSE_ANIMATION_CHANGE_BOUNDS).start();
}
}
/**
* If {@param areTouchesDisabled} is TRUE, ignore all of the user's touches.
*/
public void setDisableTouchesForSuppressLayout(boolean areTouchesDisabled) {
// The card expansion animation uses the Transition framework's ChangeBounds API. This
// invokes suppressLayout(true) on the MultiShrinkScroller. As a result, we need to avoid
// all layout changes during expansion in order to avoid weird layout artifacts.
mIsTouchDisabledForSuppressLayout = areTouchesDisabled;
}
}