From 6b3b4f49a5b53fbf937f66e5f3ba03d23980e52c Mon Sep 17 00:00:00 2001 From: Amin Shaikh Date: Tue, 6 Feb 2018 16:35:01 -0500 Subject: Add overscroll support for QS. Add SwipeDetector and OverScroll from Launcher3 to QS to acheive the same overscroll behavior. Bug: 70799330 Test: visual Change-Id: I3b83ba4e45bd090bfe5866b3320a1cbd83fce6e0 --- .../com/android/systemui/qs/QSScrollLayout.java | 103 +++++- .../com/android/systemui/qs/touch/OverScroll.java | 57 ++++ .../android/systemui/qs/touch/SwipeDetector.java | 356 +++++++++++++++++++++ 3 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/qs/touch/OverScroll.java create mode 100644 packages/SystemUI/src/com/android/systemui/qs/touch/SwipeDetector.java diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSScrollLayout.java b/packages/SystemUI/src/com/android/systemui/qs/QSScrollLayout.java index a44fd9a0f918..b8f678413e91 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSScrollLayout.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSScrollLayout.java @@ -14,8 +14,11 @@ package com.android.systemui.qs; +import android.animation.ObjectAnimator; import android.content.Context; +import android.graphics.Canvas; import android.support.v4.widget.NestedScrollView; +import android.util.Property; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; @@ -23,6 +26,8 @@ import android.view.ViewParent; import android.widget.LinearLayout; import com.android.systemui.R; +import com.android.systemui.qs.touch.OverScroll; +import com.android.systemui.qs.touch.SwipeDetector; /** * Quick setting scroll view containing the brightness slider and the QS tiles. @@ -35,6 +40,9 @@ public class QSScrollLayout extends NestedScrollView { private final int mTouchSlop; private final int mFooterHeight; private int mLastMotionY; + private final SwipeDetector mSwipeDetector; + private final OverScrollHelper mOverScrollHelper; + private float mContentTranslationY; public QSScrollLayout(Context context, View... children) { super(context); @@ -49,15 +57,19 @@ public class QSScrollLayout extends NestedScrollView { linearLayout.addView(view); } addView(linearLayout); + setOverScrollMode(OVER_SCROLL_NEVER); + mOverScrollHelper = new OverScrollHelper(); + mSwipeDetector = new SwipeDetector(context, mOverScrollHelper, SwipeDetector.VERTICAL); + mSwipeDetector.setDetectableScrollConditions(SwipeDetector.DIRECTION_BOTH, true); } - @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (canScrollVertically(1) || canScrollVertically(-1)) { return super.onInterceptTouchEvent(ev); } - return false; + mSwipeDetector.onTouchEvent(ev); + return super.onInterceptTouchEvent(ev) || mOverScrollHelper.isInOverScroll(); } @Override @@ -65,7 +77,15 @@ public class QSScrollLayout extends NestedScrollView { if (canScrollVertically(1) || canScrollVertically(-1)) { return super.onTouchEvent(ev); } - return false; + mSwipeDetector.onTouchEvent(ev); + return super.onTouchEvent(ev); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + canvas.translate(0, mContentTranslationY); + super.dispatchDraw(canvas); + canvas.translate(0, -mContentTranslationY); } public boolean shouldIntercept(MotionEvent ev) { @@ -98,4 +118,81 @@ public class QSScrollLayout extends NestedScrollView { parent.requestDisallowInterceptTouchEvent(disallowIntercept); } } + + private void setContentTranslationY(float contentTranslationY) { + mContentTranslationY = contentTranslationY; + invalidate(); + } + + private static final Property CONTENT_TRANS_Y = + new Property(Float.class, "qsScrollLayoutContentTransY") { + @Override + public Float get(QSScrollLayout qsScrollLayout) { + return qsScrollLayout.mContentTranslationY; + } + + @Override + public void set(QSScrollLayout qsScrollLayout, Float y) { + qsScrollLayout.setContentTranslationY(y); + } + }; + + private class OverScrollHelper implements SwipeDetector.Listener { + private boolean mIsInOverScroll; + + // We use this value to calculate the actual amount the user has overscrolled. + private float mFirstDisplacement = 0; + + @Override + public void onDragStart(boolean start) {} + + @Override + public boolean onDrag(float displacement, float velocity) { + // Only overscroll if the user is scrolling down when they're already at the bottom + // or scrolling up when they're already at the top. + boolean wasInOverScroll = mIsInOverScroll; + mIsInOverScroll = (!canScrollVertically(1) && displacement < 0) || + (!canScrollVertically(-1) && displacement > 0); + + if (wasInOverScroll && !mIsInOverScroll) { + // Exit overscroll. This can happen when the user is in overscroll and then + // scrolls the opposite way. Note that this causes the reset translation animation + // to run while the user is dragging, which feels a bit unnatural. + reset(); + } else if (mIsInOverScroll) { + if (Float.compare(mFirstDisplacement, 0) == 0) { + // Because users can scroll before entering overscroll, we need to + // subtract the amount where the user was not in overscroll. + mFirstDisplacement = displacement; + } + float overscrollY = displacement - mFirstDisplacement; + setContentTranslationY(getDampedOverScroll(overscrollY)); + } + + return mIsInOverScroll; + } + + @Override + public void onDragEnd(float velocity, boolean fling) { + reset(); + } + + private void reset() { + if (Float.compare(mContentTranslationY, 0) != 0) { + ObjectAnimator.ofFloat(QSScrollLayout.this, CONTENT_TRANS_Y, 0) + .setDuration(100) + .start(); + } + mIsInOverScroll = false; + mFirstDisplacement = 0; + } + + public boolean isInOverScroll() { + return mIsInOverScroll; + } + + private float getDampedOverScroll(float y) { + return OverScroll.dampedScroll(y, getHeight()); + } + } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/touch/OverScroll.java b/packages/SystemUI/src/com/android/systemui/qs/touch/OverScroll.java new file mode 100644 index 000000000000..046488679725 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/touch/OverScroll.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.qs.touch; + +/** + * Utility methods for overscroll damping and related effect. + * + * Copied from packages/apps/Launcher3/src/com/android/launcher3/touch/OverScroll.java + */ +public class OverScroll { + + private static final float OVERSCROLL_DAMP_FACTOR = 0.07f; + + /** + * This curve determines how the effect of scrolling over the limits of the page diminishes + * as the user pulls further and further from the bounds + * + * @param f The percentage of how much the user has overscrolled. + * @return A transformed percentage based on the influence curve. + */ + private static float overScrollInfluenceCurve(float f) { + f -= 1.0f; + return f * f * f + 1.0f; + } + + /** + * @param amount The original amount overscrolled. + * @param max The maximum amount that the View can overscroll. + * @return The dampened overscroll amount. + */ + public static int dampedScroll(float amount, int max) { + if (Float.compare(amount, 0) == 0) return 0; + + float f = amount / max; + f = f / (Math.abs(f)) * (overScrollInfluenceCurve(Math.abs(f))); + + // Clamp this factor, f, to -1 < f < 1 + if (Math.abs(f) >= 1) { + f /= Math.abs(f); + } + + return Math.round(OVERSCROLL_DAMP_FACTOR * f * max); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/touch/SwipeDetector.java b/packages/SystemUI/src/com/android/systemui/qs/touch/SwipeDetector.java new file mode 100644 index 000000000000..252205201e5d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/touch/SwipeDetector.java @@ -0,0 +1,356 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.qs.touch; + +import static android.view.MotionEvent.INVALID_POINTER_ID; + +import android.content.Context; +import android.graphics.PointF; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +/** + * One dimensional scroll/drag/swipe gesture detector. + * + * Definition of swipe is different from android system in that this detector handles + * 'swipe to dismiss', 'swiping up/down a container' but also keeps scrolling state before + * swipe action happens + * + * Copied from packages/apps/Launcher3/src/com/android/launcher3/touch/SwipeDetector.java + */ +public class SwipeDetector { + + private static final boolean DBG = false; + private static final String TAG = "SwipeDetector"; + + private int mScrollConditions; + public static final int DIRECTION_POSITIVE = 1 << 0; + public static final int DIRECTION_NEGATIVE = 1 << 1; + public static final int DIRECTION_BOTH = DIRECTION_NEGATIVE | DIRECTION_POSITIVE; + + private static final float ANIMATION_DURATION = 1200; + + protected int mActivePointerId = INVALID_POINTER_ID; + + /** + * The minimum release velocity in pixels per millisecond that triggers fling.. + */ + public static final float RELEASE_VELOCITY_PX_MS = 1.0f; + + /** + * The time constant used to calculate dampening in the low-pass filter of scroll velocity. + * Cutoff frequency is set at 10 Hz. + */ + public static final float SCROLL_VELOCITY_DAMPENING_RC = 1000f / (2f * (float) Math.PI * 10); + + /* Scroll state, this is set to true during dragging and animation. */ + private ScrollState mState = ScrollState.IDLE; + + enum ScrollState { + IDLE, + DRAGGING, // onDragStart, onDrag + SETTLING // onDragEnd + } + + public static abstract class Direction { + + abstract float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint); + + /** + * Distance in pixels a touch can wander before we think the user is scrolling. + */ + abstract float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos); + } + + public static final Direction VERTICAL = new Direction() { + + @Override + float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint) { + return ev.getY(pointerIndex) - refPoint.y; + } + + @Override + float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) { + return Math.abs(ev.getX(pointerIndex) - downPos.x); + } + }; + + public static final Direction HORIZONTAL = new Direction() { + + @Override + float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint) { + return ev.getX(pointerIndex) - refPoint.x; + } + + @Override + float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) { + return Math.abs(ev.getY(pointerIndex) - downPos.y); + } + }; + + //------------------- ScrollState transition diagram ----------------------------------- + // + // IDLE -> (mDisplacement > mTouchSlop) -> DRAGGING + // DRAGGING -> (MotionEvent#ACTION_UP, MotionEvent#ACTION_CANCEL) -> SETTLING + // SETTLING -> (MotionEvent#ACTION_DOWN) -> DRAGGING + // SETTLING -> (View settled) -> IDLE + + private void setState(ScrollState newState) { + if (DBG) { + Log.d(TAG, "setState:" + mState + "->" + newState); + } + // onDragStart and onDragEnd is reported ONLY on state transition + if (newState == ScrollState.DRAGGING) { + initializeDragging(); + if (mState == ScrollState.IDLE) { + reportDragStart(false /* recatch */); + } else if (mState == ScrollState.SETTLING) { + reportDragStart(true /* recatch */); + } + } + if (newState == ScrollState.SETTLING) { + reportDragEnd(); + } + + mState = newState; + } + + public boolean isDraggingOrSettling() { + return mState == ScrollState.DRAGGING || mState == ScrollState.SETTLING; + } + + /** + * There's no touch and there's no animation. + */ + public boolean isIdleState() { + return mState == ScrollState.IDLE; + } + + public boolean isSettlingState() { + return mState == ScrollState.SETTLING; + } + + public boolean isDraggingState() { + return mState == ScrollState.DRAGGING; + } + + private final PointF mDownPos = new PointF(); + private final PointF mLastPos = new PointF(); + private final Direction mDir; + + private final float mTouchSlop; + + /* Client of this gesture detector can register a callback. */ + private final Listener mListener; + + private long mCurrentMillis; + + private float mVelocity; + private float mLastDisplacement; + private float mDisplacement; + + private float mSubtractDisplacement; + private boolean mIgnoreSlopWhenSettling; + + public interface Listener { + void onDragStart(boolean start); + + boolean onDrag(float displacement, float velocity); + + void onDragEnd(float velocity, boolean fling); + } + + public SwipeDetector(@NonNull Context context, @NonNull Listener l, @NonNull Direction dir) { + this(ViewConfiguration.get(context).getScaledTouchSlop(), l, dir); + } + + @VisibleForTesting + protected SwipeDetector(float touchSlope, @NonNull Listener l, @NonNull Direction dir) { + mTouchSlop = touchSlope; + mListener = l; + mDir = dir; + } + + public void setDetectableScrollConditions(int scrollDirectionFlags, boolean ignoreSlop) { + mScrollConditions = scrollDirectionFlags; + mIgnoreSlopWhenSettling = ignoreSlop; + } + + private boolean shouldScrollStart(MotionEvent ev, int pointerIndex) { + // reject cases where the angle or slop condition is not met. + if (Math.max(mDir.getActiveTouchSlop(ev, pointerIndex, mDownPos), mTouchSlop) + > Math.abs(mDisplacement)) { + return false; + } + + // Check if the client is interested in scroll in current direction. + if (((mScrollConditions & DIRECTION_NEGATIVE) > 0 && mDisplacement > 0) || + ((mScrollConditions & DIRECTION_POSITIVE) > 0 && mDisplacement < 0)) { + return true; + } + return false; + } + + public boolean onTouchEvent(MotionEvent ev) { + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = ev.getPointerId(0); + mDownPos.set(ev.getX(), ev.getY()); + mLastPos.set(mDownPos); + mLastDisplacement = 0; + mDisplacement = 0; + mVelocity = 0; + + if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) { + setState(ScrollState.DRAGGING); + } + break; + //case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_POINTER_UP: + int ptrIdx = ev.getActionIndex(); + int ptrId = ev.getPointerId(ptrIdx); + if (ptrId == mActivePointerId) { + final int newPointerIdx = ptrIdx == 0 ? 1 : 0; + mDownPos.set( + ev.getX(newPointerIdx) - (mLastPos.x - mDownPos.x), + ev.getY(newPointerIdx) - (mLastPos.y - mDownPos.y)); + mLastPos.set(ev.getX(newPointerIdx), ev.getY(newPointerIdx)); + mActivePointerId = ev.getPointerId(newPointerIdx); + } + break; + case MotionEvent.ACTION_MOVE: + int pointerIndex = ev.findPointerIndex(mActivePointerId); + if (pointerIndex == INVALID_POINTER_ID) { + break; + } + mDisplacement = mDir.getDisplacement(ev, pointerIndex, mDownPos); + computeVelocity(mDir.getDisplacement(ev, pointerIndex, mLastPos), + ev.getEventTime()); + + // handle state and listener calls. + if (mState != ScrollState.DRAGGING && shouldScrollStart(ev, pointerIndex)) { + setState(ScrollState.DRAGGING); + } + if (mState == ScrollState.DRAGGING) { + reportDragging(); + } + mLastPos.set(ev.getX(pointerIndex), ev.getY(pointerIndex)); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + // These are synthetic events and there is no need to update internal values. + if (mState == ScrollState.DRAGGING) { + setState(ScrollState.SETTLING); + } + break; + default: + break; + } + return true; + } + + public void finishedScrolling() { + setState(ScrollState.IDLE); + } + + private boolean reportDragStart(boolean recatch) { + mListener.onDragStart(!recatch); + if (DBG) { + Log.d(TAG, "onDragStart recatch:" + recatch); + } + return true; + } + + private void initializeDragging() { + if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) { + mSubtractDisplacement = 0; + } + if (mDisplacement > 0) { + mSubtractDisplacement = mTouchSlop; + } else { + mSubtractDisplacement = -mTouchSlop; + } + } + + private boolean reportDragging() { + if (mDisplacement != mLastDisplacement) { + if (DBG) { + Log.d(TAG, String.format("onDrag disp=%.1f, velocity=%.1f", + mDisplacement, mVelocity)); + } + + mLastDisplacement = mDisplacement; + return mListener.onDrag(mDisplacement - mSubtractDisplacement, mVelocity); + } + return true; + } + + private void reportDragEnd() { + if (DBG) { + Log.d(TAG, String.format("onScrollEnd disp=%.1f, velocity=%.1f", + mDisplacement, mVelocity)); + } + mListener.onDragEnd(mVelocity, Math.abs(mVelocity) > RELEASE_VELOCITY_PX_MS); + + } + + /** + * Computes the damped velocity. + */ + public float computeVelocity(float delta, long currentMillis) { + long previousMillis = mCurrentMillis; + mCurrentMillis = currentMillis; + + float deltaTimeMillis = mCurrentMillis - previousMillis; + float velocity = (deltaTimeMillis > 0) ? (delta / deltaTimeMillis) : 0; + if (Math.abs(mVelocity) < 0.001f) { + mVelocity = velocity; + } else { + float alpha = computeDampeningFactor(deltaTimeMillis); + mVelocity = interpolate(mVelocity, velocity, alpha); + } + return mVelocity; + } + + /** + * Returns a time-dependent dampening factor using delta time. + */ + private static float computeDampeningFactor(float deltaTime) { + return deltaTime / (SCROLL_VELOCITY_DAMPENING_RC + deltaTime); + } + + /** + * Returns the linear interpolation between two values + */ + private static float interpolate(float from, float to, float alpha) { + return (1.0f - alpha) * from + alpha * to; + } + + public static long calculateDuration(float velocity, float progressNeeded) { + // TODO: make these values constants after tuning. + float velocityDivisor = Math.max(2f, Math.abs(0.5f * velocity)); + float travelDistance = Math.max(0.2f, progressNeeded); + long duration = (long) Math.max(100, ANIMATION_DURATION / velocityDivisor * travelDistance); + if (DBG) { + Log.d(TAG, String.format("calculateDuration=%d, v=%f, d=%f", duration, velocity, progressNeeded)); + } + return duration; + } +} + -- cgit v1.2.3-59-g8ed1b