Don't accidentally scroll QC off screen.
Behavior changes
1) Halt fling once it hits the top of the window
2) Add top EdgeEffect
3) Add springyness
These changes result in much simpler nested-scrolling
calculations.
UX may want to change some of the constants
after playing with this for a day. However, the
logic changes should remain unchanged.
Bug: 18571742
Change-Id: Icbc063beb2061bf7cd1e4401b16e604415140242
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index b31b5b9..d9e541a 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -24,6 +24,14 @@
<dimen name="quickcontact_title_initial_margin">16dp</dimen>
<!-- The ratio of width:height for the contact's photo in landscape -->
<item name="quickcontact_landscape_photo_ratio" type="dimen" format="float">0.7</item>
+ <!-- How far QuickContacts can be dragged and released from the top of the window before we dismiss it. -->
+ <dimen name="quickcontact_dismiss_distance_on_release">40dp</dimen>
+ <!-- How far QuickContacts can be dragged from the top of the window before we dismiss it. -->
+ <dimen name="quickcontact_dismiss_distance_on_scroll">100dp</dimen>
+ <!-- When first flinging QuickContacts towards the top of the window if the fling is
+ predicted to scroll past the window top by less than this amount, then QuickContacts
+ snaps to the top of the window. -->
+ <dimen name="quickcontact_snap_to_top_slop_height">33dp</dimen>
<!-- Padding of the rounded plus/minus/expand/collapse buttons in the editor -->
<dimen name="editor_round_button_padding_left">16dip</dimen>
diff --git a/src/com/android/contacts/widget/MultiShrinkScroller.java b/src/com/android/contacts/widget/MultiShrinkScroller.java
index f7f0c7b..095198a 100644
--- a/src/com/android/contacts/widget/MultiShrinkScroller.java
+++ b/src/com/android/contacts/widget/MultiShrinkScroller.java
@@ -12,7 +12,6 @@
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.content.Context;
-import android.content.res.Configuration;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
@@ -60,8 +59,8 @@
* 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 specific animations such as the ones
- * inside snapToBottom(). As a result this ViewGroup has non-standard talkback and keyboard support.
+ * 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 {
@@ -73,7 +72,7 @@
/**
* Length of the acceleration animations. This value was taken from ValueAnimator.java.
*/
- private static final int EXIT_FLING_ANIMATION_DURATION_MS = 300;
+ private static final int EXIT_FLING_ANIMATION_DURATION_MS = 250;
/**
* In portrait mode, the height:width ratio of the photo's starting height.
@@ -86,21 +85,22 @@
*/
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;
- /**
- * Maximum velocity for flings in dips per second. Picked via non-rigorous experimentation.
- */
- private static final float MAXIMUM_FLING_VELOCITY = 2000;
-
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;
@@ -140,12 +140,17 @@
* True once the header has touched the top of the screen at least once.
*/
private boolean mHasEverTouchedTheTop;
+ private boolean mIsTouchDisabledForDismissAnimation;
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;
@@ -240,12 +245,11 @@
setWillNotDraw(/* willNotDraw = */ false);
mEdgeGlowBottom = new EdgeEffect(context);
+ mEdgeGlowTop = new EdgeEffect(context);
mScroller = new Scroller(context, sInterpolator);
mTouchSlop = configuration.getScaledTouchSlop();
mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
- mMaximumVelocity = (int)TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, MAXIMUM_FLING_VELOCITY,
- getResources().getDisplayMetrics());
+ mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
mTransparentStartHeight = (int) getResources().getDimension(
R.dimen.quickcontact_starting_empty_height);
mToolbarElevation = getResources().getDimension(
@@ -254,6 +258,13 @@
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);
@@ -395,6 +406,8 @@
}
private boolean shouldStartDrag(MotionEvent event) {
+ if (mIsTouchDisabledForDismissAnimation) return false;
+
if (mIsBeingDragged) {
mIsBeingDragged = false;
return false;
@@ -429,6 +442,8 @@
@Override
public boolean onTouchEvent(MotionEvent event) {
+ if (mIsTouchDisabledForDismissAnimation) return true;
+
final int action = event.getAction();
if (mVelocityTracker == null) {
@@ -466,6 +481,10 @@
postInvalidateOnAnimation();
}
+ if (shouldDismissOnScroll()) {
+ scrollOffBottom();
+ }
+
}
break;
@@ -485,6 +504,7 @@
// We want to 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());
}
/**
@@ -531,31 +551,43 @@
}
private void onDragFinished(int flingDelta) {
- if (!snapToTop(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.
- snapToBottom(flingDelta);
+ 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 snapToTop(int flingDelta) {
- if (mHasEverTouchedTheTop) {
- // Only when first interacting with QuickContacts should QuickContacts snap to the top
- // of the screen. After this, QuickContacts can be placed most anywhere on the screen.
+ 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;
}
- final int requiredScroll = -getScroll_ignoreOversizedHeaderForSnapping()
- + mTransparentStartHeight;
- if (-getScroll_ignoreOversizedHeaderForSnapping() - flingDelta < 0
- && -getScroll_ignoreOversizedHeaderForSnapping() - flingDelta >
- -mTransparentStartHeight && requiredScroll != 0) {
- // We finish scrolling above the empty starting height, and aren't projected
- // to fling past the top of the Window, so elastically snap the empty space shut.
+ if (getTransparentViewHeight() < mDismissDistanceOnRelease) {
mScroller.forceFinished(true);
- smoothScrollBy(requiredScroll);
+ smoothScrollBy(getTransparentViewHeight());
return true;
}
return false;
@@ -564,36 +596,27 @@
/**
* If needed, scroll all the subviews off the bottom of the Window.
*/
- private void snapToBottom(int flingDelta) {
+ private void snapToBottomOnDragFinished() {
if (mHasEverTouchedTheTop) {
- // If QuickContacts has touched the top of the screen previously, then we
- // will less aggressively snap to the bottom of the screen.
- final float predictedScrollPastTop = -getScroll() + mTransparentStartHeight
- - flingDelta;
- final boolean isLandscape = getResources().getConfiguration().orientation
- == Configuration.ORIENTATION_LANDSCAPE;
- if (isLandscape) {
- // In landscape orientation, we dismiss the QC once it goes below the starting
- // starting offset that is used when QC starts in collapsed mode.
- if (predictedScrollPastTop > mTransparentStartHeight) {
- scrollOffBottom();
- }
- } else {
- // In portrait orientation, we dismiss the QC once it goes below
- // mIntermediateHeaderHeight within the bottom of the screen.
- final float heightMinusHeader = getHeight() - mIntermediateHeaderHeight;
- if (predictedScrollPastTop > heightMinusHeader) {
- scrollOffBottom();
- }
+ if (getTransparentViewHeight() > mDismissDistanceOnRelease) {
+ scrollOffBottom();
}
return;
}
- if (-getScroll() - flingDelta > 0) {
+ 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() {
@@ -607,6 +630,7 @@
}
public void scrollOffBottom() {
+ mIsTouchDisabledForDismissAnimation = true;
final Interpolator interpolator = new AcceleratingFlingInterpolator(
EXIT_FLING_ANIMATION_DURATION_MS, getCurrentVelocity(),
getScrollUntilOffBottom());
@@ -770,7 +794,7 @@
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
- // Examine the fling results in order to activate EdgeEffect when we fling to the end.
+ // 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;
@@ -778,13 +802,21 @@
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;
}
}
}
@@ -793,10 +825,11 @@
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();
- 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
@@ -820,6 +853,22 @@
}
canvas.restoreToCount(restoreCount);
}
+
+ if (!mEdgeGlowTop.isFinished()) {
+ final int restoreCount = canvas.save();
+ if (mIsTwoPanel) {
+ mEdgeGlowTop.setSize(mScrollView.getWidth(), height);
+ if (!isLayoutRtl()) {
+ canvas.translate(mPhotoViewContainer.getWidth(), 0);
+ }
+ } else {
+ mEdgeGlowTop.setSize(width, height);
+ }
+ if (mEdgeGlowTop.draw(canvas)) {
+ postInvalidateOnAnimation();
+ }
+ canvas.restoreToCount(restoreCount);
+ }
}
private float getCurrentVelocity() {
@@ -831,13 +880,13 @@
}
private void fling(float velocity) {
- if (Math.abs(mMaximumVelocity) < Math.abs(velocity)) {
- velocity = -mMaximumVelocity * Math.signum(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();
}
@@ -1163,7 +1212,13 @@
final int VERTICAL = 1;
final float position = mLastEventPosition[VERTICAL];
updateLastEventPosition(event);
- return position - mLastEventPosition[VERTICAL];
+ 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) {