blob: 9df3c61e51ec2869d7da534993c86e12bd88f050 [file] [log] [blame]
package com.android.contacts.widget;
import com.android.contacts.R;
import com.android.contacts.test.NeededForReflection;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewConfiguration;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.EdgeEffect;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.Scroller;
import android.widget.ScrollView;
/**
* 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.
*/
public class MultiShrinkScroller extends LinearLayout {
/**
* 1000 pixels per millisecond. Ie, 1 pixel per second.
*/
private static final int PIXELS_PER_SECOND = 1000;
private float[] mLastEventPosition = { 0, 0 };
private VelocityTracker mVelocityTracker;
private boolean mIsBeingDragged = false;
private boolean mReceivedDown = false;
private ScrollView mScrollView;
private View mScrollViewChild;
private View mToolbar;
private ImageView mPhotoView;
private MultiShrinkScrollerListener mListener;
private int mHeaderTintColor;
private final Scroller mScroller;
private final EdgeEffect mEdgeGlowBottom;
private final int mTouchSlop;
private final int mMaximumVelocity;
private final int mMinimumVelocity;
private final int mMaximumHeaderHeight;
private final int mMinimumHeaderHeight;
private final int mTransparentStartHeight;
private final int mElasticScrollOverTopRegion;
private final PorterDuffColorFilter mColorFilter
= new PorterDuffColorFilter(0, PorterDuff.Mode.SRC_ATOP);
public interface MultiShrinkScrollerListener {
void onScrolledOffBottom();
}
// Interpolator from android.support.v4.view.ViewPager
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);
mScroller = new Scroller(context, sInterpolator);
mTouchSlop = configuration.getScaledTouchSlop();
mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
mMaximumHeaderHeight = (int) getResources().getDimension(
R.dimen.quickcontact_maximum_header_height);
mMinimumHeaderHeight = (int) getResources().getDimension(
R.dimen.quickcontact_minimum_header_height);
mTransparentStartHeight = (int) getResources().getDimension(
R.dimen.quickcontact_starting_empty_height);
mElasticScrollOverTopRegion = (int) getResources().getDimension(
R.dimen.quickcontact_elastic_scroll_over_top_region);
mHeaderTintColor = mContext.getResources().getColor(
R.color.actionbar_background_color);
}
/**
* This method must be called inside the Activity's OnCreate.
*/
public void initialize(MultiShrinkScrollerListener listener) {
mScrollView = (ScrollView) findViewById(R.id.content_scroller);
mScrollViewChild = findViewById(R.id.card_container);
mToolbar = findViewById(R.id.toolbar_parent);
mPhotoView = (ImageView) findViewById(R.id.photo);
mListener = listener;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent 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 (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) {
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 heightScrollViewChild = mScrollViewChild.getHeight();
final int pulledToY = mScrollView.getScrollY() + (int) delta;
if (pulledToY > heightScrollViewChild - mScrollView.getHeight()
&& mToolbar.getHeight() == mMinimumHeaderHeight) {
// The ScrollView is being pulled upwards while there is no more
// content offscreen, and the view port is already fully expanded.
mEdgeGlowBottom.onPull(delta / getHeight(), 1 - event.getX() / getWidth());
}
if (!mEdgeGlowBottom.isFinished()) {
postInvalidateOnAnimation();
}
}
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;
updatePhotoTint();
}
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 (!snapToTop(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.
snapToBottom(flingDelta);
}
}
/**
* If needed, snap the subviews to the top of the Window.
*/
private boolean snapToTop(int flingDelta) {
if (-getScroll() - flingDelta < 0
&& -getScroll() - flingDelta > -mTransparentStartHeight
- mElasticScrollOverTopRegion) {
// We finish scrolling above the empty starting height, and aren't projected
// to fling past the top of the Window by mElasticScrollOverTopRegion worth of
// pixels, so elastically snap the empty space shut.
mScroller.forceFinished(true);
smoothScrollBy(-getScroll() + mTransparentStartHeight);
return true;
}
return false;
}
/**
* If needed, scroll all the subviews off the bottom of the Window.
*/
private void snapToBottom(int flingDelta) {
if (-getScroll() - flingDelta > 0) {
mScroller.forceFinished(true);
ObjectAnimator translateAnimation = ObjectAnimator.ofInt(this, "scroll",
getScroll() - getScrollUntilOffBottom());
translateAnimation.setRepeatCount(0);
translateAnimation.setInterpolator(new AccelerateInterpolator());
translateAnimation.start();
}
}
@Override
public void scrollTo(int x, int y) {
int delta = y - getScroll();
if (delta > 0) {
scrollUp(delta);
} else {
scrollDown(delta);
}
updatePhotoTint();
}
@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.
*/
public int getScroll() {
final LinearLayout.LayoutParams toolbarLayoutParams
= (LayoutParams) mToolbar.getLayoutParams();
return mTransparentStartHeight - toolbarLayoutParams.topMargin
+ mMaximumHeaderHeight - toolbarLayoutParams.height + mScrollView.getScrollY();
}
/**
* Return amount of scrolling needed in order for all the visible subviews to scroll off the
* bottom.
*/
public int getScrollUntilOffBottom() {
return getHeight() + getScroll() - mTransparentStartHeight;
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
// Examine the fling results in order to activate EdgeEffect when we fling to the end.
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 (!awakenScrollBars()) {
// Keep on drawing until the animation has finished.
postInvalidateOnAnimation();
}
if (mScroller.getCurrY() >= getMaximumScrollUpwards()) {
mScroller.abortAnimation();
}
}
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
if (!mEdgeGlowBottom.isFinished()) {
final int restoreCount = canvas.save();
final int width = getWidth() - getPaddingLeft() - getPaddingRight();
final int height = getHeight();
// 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);
mEdgeGlowBottom.setSize(width, height);
if (mEdgeGlowBottom.draw(canvas)) {
postInvalidateOnAnimation();
}
canvas.restoreToCount(restoreCount);
}
}
private float getCurrentVelocity() {
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);
invalidate();
}
private int getMaximumScrollUpwards() {
return mTransparentStartHeight
// How much the Header view can compress
+ mMaximumHeaderHeight - mMinimumHeaderHeight
// How much the ScrollView can scroll. 0, if child is smaller than ScrollView.
+ Math.max(0, mScrollViewChild.getHeight() - getHeight() + mMinimumHeaderHeight);
}
private void scrollUp(int delta) {
LinearLayout.LayoutParams toolbarLayoutParams = (LayoutParams) mToolbar.getLayoutParams();
if (toolbarLayoutParams.topMargin != 0) {
final int originalValue = toolbarLayoutParams.topMargin;
toolbarLayoutParams.topMargin -= delta;
toolbarLayoutParams.topMargin = Math.max(toolbarLayoutParams.topMargin, 0);
mToolbar.setLayoutParams(toolbarLayoutParams);
delta -= originalValue - toolbarLayoutParams.topMargin;
}
if (toolbarLayoutParams.height != mMinimumHeaderHeight) {
final int originalValue = toolbarLayoutParams.height;
toolbarLayoutParams.height -= delta;
toolbarLayoutParams.height = Math.max(toolbarLayoutParams.height, mMinimumHeaderHeight);
mToolbar.setLayoutParams(toolbarLayoutParams);
delta -= originalValue - toolbarLayoutParams.height;
}
mScrollView.scrollBy(0, delta);
}
private void scrollDown(int delta) {
LinearLayout.LayoutParams toolbarLayoutParams = (LayoutParams) mToolbar.getLayoutParams();
if (mScrollView.getScrollY() > 0) {
final int originalValue = mScrollView.getScrollY();
mScrollView.scrollBy(0, delta);
delta -= mScrollView.getScrollY() - originalValue;
}
if (toolbarLayoutParams.height != mMaximumHeaderHeight) {
final int originalValue = toolbarLayoutParams.height;
toolbarLayoutParams.height -= delta;
toolbarLayoutParams.height = Math.min(toolbarLayoutParams.height, mMaximumHeaderHeight);
mToolbar.setLayoutParams(toolbarLayoutParams);
delta -= originalValue - toolbarLayoutParams.height;
}
toolbarLayoutParams.topMargin -= delta;
mToolbar.setLayoutParams(toolbarLayoutParams);
if (mListener != null && getScrollUntilOffBottom() <= 0) {
post(new Runnable() {
@Override
public void run() {
mListener.onScrolledOffBottom();
}
});
}
}
private void updatePhotoTint() {
// 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 = mToolbar.getLayoutParams().height;
// Reuse an existing mColorFilter (to avoid GC pauses) to change the photo's tint.
mPhotoView.clearColorFilter();
if (toolbarHeight >= mMaximumHeaderHeight) {
return;
}
if (toolbarHeight <= mMinimumHeaderHeight) {
mColorFilter.setColor(mHeaderTintColor);
mPhotoView.setColorFilter(mColorFilter);
} else {
final int alphaBits = 0xff - 0xff * (mToolbar.getHeight() - mMinimumHeaderHeight)
/ (mMaximumHeaderHeight - mMinimumHeaderHeight);
final int color = alphaBits << 24 | (mHeaderTintColor & 0xffffff);
mColorFilter.setColor(color);
mPhotoView.setColorFilter(mColorFilter);
}
}
private void updateLastEventPosition(MotionEvent event) {
mLastEventPosition[0] = event.getX();
mLastEventPosition[1] = event.getY();
}
private boolean motionShouldStartDrag(MotionEvent event) {
final float deltaX = event.getX() - mLastEventPosition[0];
final float deltaY = event.getY() - mLastEventPosition[1];
final boolean draggedX = (deltaX > mTouchSlop || deltaX < -mTouchSlop);
final boolean draggedY = (deltaY > mTouchSlop || deltaY < -mTouchSlop);
return draggedY && !draggedX;
}
private float updatePositionAndComputeDelta(MotionEvent event) {
final int VERTICAL = 1;
final float position = mLastEventPosition[VERTICAL];
updateLastEventPosition(event);
return position - mLastEventPosition[VERTICAL];
}
private void smoothScrollBy(int delta) {
mScroller.startScroll(0, getScroll(), 0, delta);
invalidate();
}
}