feat(magnification fling): add fling momentum velocity tracking to panning gesture
Make the fullscreen magnification nice and slippery, like a wet dog ice
skating with skates made of butter.
Unfortunately, we can't add tests to
FullScreenMagnificationControllerTest, because it uses mock animators
and custom Handler timing, but Scroller is wired directly to the real
ones which we can't fake/mock.
Fix: 319175022
Flag: ACONFIG services.accessibility.fullscreen_fling_gesture DISABLED
Test: atest com.android.server.accessibility.magnification.FullScreenMagnificationGestureHandlerTest
NO_IFTTT=just TODO comments
Change-Id: Ic37278895846ed46604bb506ecf41177b94b0d17
diff --git a/services/accessibility/accessibility.aconfig b/services/accessibility/accessibility.aconfig
index 44682e2..f902439 100644
--- a/services/accessibility/accessibility.aconfig
+++ b/services/accessibility/accessibility.aconfig
@@ -52,6 +52,13 @@
}
flag {
+ name: "fullscreen_fling_gesture"
+ namespace: "accessibility"
+ description: "When true, adds a fling gesture animation for fullscreen magnification"
+ bug: "319175022"
+}
+
+flag {
name: "pinch_zoom_zero_min_span"
namespace: "accessibility"
description: "Whether to set min span of ScaleGestureDetector to zero."
diff --git a/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java b/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java
index 805f6e3..a73f88b 100644
--- a/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java
+++ b/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java
@@ -25,7 +25,9 @@
import android.accessibilityservice.MagnificationConfig;
import android.animation.Animator;
+import android.animation.TimeAnimator;
import android.animation.ValueAnimator;
+import android.annotation.MainThread;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.BroadcastReceiver;
@@ -51,6 +53,7 @@
import android.view.WindowManager;
import android.view.accessibility.MagnificationAnimationCallback;
import android.view.animation.DecelerateInterpolator;
+import android.widget.Scroller;
import com.android.internal.R;
import com.android.internal.accessibility.common.MagnificationConstants;
@@ -60,6 +63,7 @@
import com.android.server.LocalServices;
import com.android.server.accessibility.AccessibilityManagerService;
import com.android.server.accessibility.AccessibilityTraceManager;
+import com.android.server.accessibility.Flags;
import com.android.server.wm.WindowManagerInternal;
import java.util.ArrayList;
@@ -86,6 +90,7 @@
private static final boolean DEBUG_SET_MAGNIFICATION_SPEC = false;
private final Object mLock;
+ private final Supplier<Scroller> mScrollerSupplier;
private final ControllerContext mControllerCtx;
@@ -149,7 +154,8 @@
DisplayMagnification(int displayId) {
mDisplayId = displayId;
- mSpecAnimationBridge = new SpecAnimationBridge(mControllerCtx, mLock, mDisplayId);
+ mSpecAnimationBridge =
+ new SpecAnimationBridge(mControllerCtx, mLock, mDisplayId, mScrollerSupplier);
}
/**
@@ -406,6 +412,41 @@
}
}
+ void startFlingAnimation(
+ float xPixelsPerSecond,
+ float yPixelsPerSecond,
+ MagnificationAnimationCallback animationCallback
+ ) {
+ if (DEBUG) {
+ Slog.i(LOG_TAG,
+ "startFlingAnimation(spec = " + xPixelsPerSecond + ", animationCallback = "
+ + animationCallback + ")");
+ }
+ if (Thread.currentThread().getId() == mMainThreadId) {
+ mSpecAnimationBridge.startFlingAnimation(
+ xPixelsPerSecond,
+ yPixelsPerSecond,
+ getMinOffsetXLocked(),
+ getMaxOffsetXLocked(),
+ getMinOffsetYLocked(),
+ getMaxOffsetYLocked(),
+ animationCallback);
+ } else {
+ final Message m =
+ PooledLambda.obtainMessage(
+ SpecAnimationBridge::startFlingAnimation,
+ mSpecAnimationBridge,
+ xPixelsPerSecond,
+ yPixelsPerSecond,
+ getMinOffsetXLocked(),
+ getMaxOffsetXLocked(),
+ getMinOffsetYLocked(),
+ getMaxOffsetYLocked(),
+ animationCallback);
+ mControllerCtx.getHandler().sendMessage(m);
+ }
+ }
+
/**
* Get the ID of the last service that changed the magnification spec.
*
@@ -759,6 +800,49 @@
sendSpecToAnimation(mCurrentMagnificationSpec, null);
}
+ @GuardedBy("mLock")
+ void startFling(float xPixelsPerSecond, float yPixelsPerSecond, int id) {
+ if (!mRegistered) {
+ return;
+ }
+ if (!isActivated()) {
+ return;
+ }
+
+ if (id != INVALID_SERVICE_ID) {
+ mIdOfLastServiceToMagnify = id;
+ }
+
+ startFlingAnimation(
+ xPixelsPerSecond,
+ yPixelsPerSecond,
+ new MagnificationAnimationCallback() {
+ @Override
+ public void onResult(boolean success) {
+ // never called
+ }
+
+ @Override
+ public void onResult(boolean success, MagnificationSpec lastSpecSent) {
+ if (DEBUG) {
+ Slog.i(
+ LOG_TAG,
+ "startFlingAnimation finished( "
+ + success
+ + " = "
+ + lastSpecSent.offsetX
+ + ", "
+ + lastSpecSent.offsetY
+ + ")");
+ }
+ synchronized (mLock) {
+ mCurrentMagnificationSpec.setTo(lastSpecSent);
+ onMagnificationChangedLocked();
+ }
+ }
+ });
+ }
+
boolean updateCurrentSpecWithOffsetsLocked(float nonNormOffsetX, float nonNormOffsetY) {
if (DEBUG) {
Slog.i(LOG_TAG,
@@ -838,7 +922,8 @@
magnificationInfoChangedCallback,
scaleProvider,
/* thumbnailSupplier= */ null,
- backgroundExecutor);
+ backgroundExecutor,
+ () -> new Scroller(context));
}
/** Constructor for tests */
@@ -849,9 +934,11 @@
@NonNull MagnificationInfoChangedCallback magnificationInfoChangedCallback,
@NonNull MagnificationScaleProvider scaleProvider,
Supplier<MagnificationThumbnail> thumbnailSupplier,
- @NonNull Executor backgroundExecutor) {
+ @NonNull Executor backgroundExecutor,
+ Supplier<Scroller> scrollerSupplier) {
mControllerCtx = ctx;
mLock = lock;
+ mScrollerSupplier = scrollerSupplier;
mMainThreadId = mControllerCtx.getContext().getMainLooper().getThread().getId();
mScreenStateObserver = new ScreenStateObserver(mControllerCtx.getContext(), this);
addInfoChangedCallback(magnificationInfoChangedCallback);
@@ -1437,6 +1524,26 @@
}
/**
+ * Call after a pan ends, if the velocity has passed the threshold, to start a fling animation.
+ *
+ * @param displayId The logical display id.
+ * @param xPixelsPerSecond the velocity of the last pan gesture in the X direction, in current
+ * screen pixels per second.
+ * @param yPixelsPerSecond the velocity of the last pan gesture in the Y direction, in current
+ * screen pixels per second.
+ * @param id the ID of the service requesting the change
+ */
+ public void startFling(int displayId, float xPixelsPerSecond, float yPixelsPerSecond, int id) {
+ synchronized (mLock) {
+ final DisplayMagnification display = mDisplays.get(displayId);
+ if (display == null) {
+ return;
+ }
+ display.startFling(xPixelsPerSecond, yPixelsPerSecond, id);
+ }
+ }
+
+ /**
* Get the ID of the last service that changed the magnification spec.
*
* @param displayId The logical display id.
@@ -1698,7 +1805,14 @@
@GuardedBy("mLock")
private boolean mEnabled = false;
- private SpecAnimationBridge(ControllerContext ctx, Object lock, int displayId) {
+ private final Scroller mScroller;
+ private final TimeAnimator mScrollAnimator = new TimeAnimator();
+
+ private SpecAnimationBridge(
+ ControllerContext ctx,
+ Object lock,
+ int displayId,
+ Supplier<Scroller> scrollerSupplier) {
mControllerCtx = ctx;
mLock = lock;
mDisplayId = displayId;
@@ -1709,6 +1823,35 @@
mValueAnimator.setFloatValues(0.0f, 1.0f);
mValueAnimator.addUpdateListener(this);
mValueAnimator.addListener(this);
+
+ if (Flags.fullscreenFlingGesture()) {
+ mScroller = scrollerSupplier.get();
+ mScrollAnimator.addListener(this);
+ mScrollAnimator.setTimeListener(
+ (animation, totalTime, deltaTime) -> {
+ synchronized (mLock) {
+ if (DEBUG) {
+ Slog.v(
+ LOG_TAG,
+ "onScrollAnimationUpdate: "
+ + mEnabled + " : " + totalTime);
+ }
+
+ if (mEnabled) {
+ if (!mScroller.computeScrollOffset()) {
+ animation.end();
+ return;
+ }
+
+ mEndMagnificationSpec.offsetX = mScroller.getCurrX();
+ mEndMagnificationSpec.offsetY = mScroller.getCurrY();
+ setMagnificationSpecLocked(mEndMagnificationSpec);
+ }
+ }
+ });
+ } else {
+ mScroller = null;
+ }
}
/**
@@ -1735,16 +1878,20 @@
}
}
- void updateSentSpecMainThread(MagnificationSpec spec,
- MagnificationAnimationCallback animationCallback) {
- if (mValueAnimator.isRunning()) {
- mValueAnimator.cancel();
- }
+ @MainThread
+ void updateSentSpecMainThread(
+ MagnificationSpec spec, MagnificationAnimationCallback animationCallback) {
+ cancelAnimations();
mAnimationCallback = animationCallback;
// If the current and sent specs don't match, update the sent spec.
synchronized (mLock) {
final boolean changed = !mSentMagnificationSpec.equals(spec);
+ if (DEBUG_SET_MAGNIFICATION_SPEC) {
+ Slog.d(
+ LOG_TAG,
+ "updateSentSpecMainThread: " + mEnabled + " : changed: " + changed);
+ }
if (changed) {
if (mAnimationCallback != null) {
animateMagnificationSpecLocked(spec);
@@ -1757,12 +1904,13 @@
}
}
+ @MainThread
private void sendEndCallbackMainThread(boolean success) {
if (mAnimationCallback != null) {
if (DEBUG) {
Slog.d(LOG_TAG, "sendEndCallbackMainThread: " + success);
}
- mAnimationCallback.onResult(success);
+ mAnimationCallback.onResult(success, mSentMagnificationSpec);
mAnimationCallback = null;
}
}
@@ -1830,6 +1978,72 @@
public void onAnimationRepeat(Animator animation) {
}
+
+ /**
+ * Call after a pan ends, if the velocity has passed the threshold, to start a fling
+ * animation.
+ */
+ @MainThread
+ public void startFlingAnimation(
+ float xPixelsPerSecond,
+ float yPixelsPerSecond,
+ float minX,
+ float maxX,
+ float minY,
+ float maxY,
+ MagnificationAnimationCallback animationCallback
+ ) {
+ if (!Flags.fullscreenFlingGesture()) {
+ return;
+ }
+ cancelAnimations();
+
+ mAnimationCallback = animationCallback;
+
+ // We use this as a temp object to send updates every animation frame, so make sure it
+ // matches the current spec before we start.
+ mEndMagnificationSpec.setTo(mSentMagnificationSpec);
+
+ if (DEBUG) {
+ Slog.d(LOG_TAG, "startFlingAnimation: "
+ + "offsetX " + mSentMagnificationSpec.offsetX
+ + "offsetY " + mSentMagnificationSpec.offsetY
+ + "xPixelsPerSecond " + xPixelsPerSecond
+ + "yPixelsPerSecond " + yPixelsPerSecond
+ + "minX " + minX
+ + "maxX " + maxX
+ + "minY " + minY
+ + "maxY " + maxY
+ );
+ }
+
+ mScroller.fling(
+ (int) mSentMagnificationSpec.offsetX,
+ (int) mSentMagnificationSpec.offsetY,
+ (int) xPixelsPerSecond,
+ (int) yPixelsPerSecond,
+ (int) minX,
+ (int) maxX,
+ (int) minY,
+ (int) maxY);
+
+ mScrollAnimator.start();
+ }
+
+ @MainThread
+ private void cancelAnimations() {
+ if (mValueAnimator.isRunning()) {
+ mValueAnimator.cancel();
+ }
+
+ if (!Flags.fullscreenFlingGesture()) {
+ return;
+ }
+ if (mScrollAnimator.isRunning()) {
+ mScrollAnimator.cancel();
+ }
+ mScroller.forceFinished(true);
+ }
}
private static class ScreenStateObserver extends BroadcastReceiver {
diff --git a/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationGestureHandler.java b/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationGestureHandler.java
index baae1d93..44a34ca 100644
--- a/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationGestureHandler.java
+++ b/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationGestureHandler.java
@@ -62,6 +62,7 @@
import android.view.MotionEvent.PointerProperties;
import android.view.ScaleGestureDetector;
import android.view.ScaleGestureDetector.OnScaleGestureListener;
+import android.view.VelocityTracker;
import android.view.ViewConfiguration;
import com.android.internal.R;
@@ -174,6 +175,10 @@
private final boolean mIsWatch;
+ @Nullable private VelocityTracker mVelocityTracker;
+ private final int mMinimumVelocity;
+ private final int mMaximumVelocity;
+
public FullScreenMagnificationGestureHandler(@UiContext Context context,
FullScreenMagnificationController fullScreenMagnificationController,
AccessibilityTraceManager trace,
@@ -184,15 +189,25 @@
@NonNull WindowMagnificationPromptController promptController,
int displayId,
FullScreenMagnificationVibrationHelper fullScreenMagnificationVibrationHelper) {
- this(context, fullScreenMagnificationController, trace, callback,
- detectSingleFingerTripleTap, detectTwoFingerTripleTap,
- detectShortcutTrigger, promptController, displayId,
- fullScreenMagnificationVibrationHelper, /* magnificationLogger= */ null);
+ this(
+ context,
+ fullScreenMagnificationController,
+ trace,
+ callback,
+ detectSingleFingerTripleTap,
+ detectTwoFingerTripleTap,
+ detectShortcutTrigger,
+ promptController,
+ displayId,
+ fullScreenMagnificationVibrationHelper,
+ /* magnificationLogger= */ null,
+ ViewConfiguration.get(context));
}
/** Constructor for tests. */
@VisibleForTesting
- FullScreenMagnificationGestureHandler(@UiContext Context context,
+ FullScreenMagnificationGestureHandler(
+ @UiContext Context context,
FullScreenMagnificationController fullScreenMagnificationController,
AccessibilityTraceManager trace,
Callback callback,
@@ -202,7 +217,8 @@
@NonNull WindowMagnificationPromptController promptController,
int displayId,
FullScreenMagnificationVibrationHelper fullScreenMagnificationVibrationHelper,
- MagnificationLogger magnificationLogger) {
+ MagnificationLogger magnificationLogger,
+ ViewConfiguration viewConfiguration) {
super(displayId, detectSingleFingerTripleTap, detectTwoFingerTripleTap,
detectShortcutTrigger, trace, callback);
if (DEBUG_ALL) {
@@ -212,6 +228,15 @@
+ ", detectTwoFingerTripleTap = " + detectTwoFingerTripleTap
+ ", detectShortcutTrigger = " + detectShortcutTrigger + ")");
}
+
+ if (Flags.fullscreenFlingGesture()) {
+ mMinimumVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
+ mMaximumVelocity = viewConfiguration.getScaledMaximumFlingVelocity();
+ } else {
+ mMinimumVelocity = 0;
+ mMaximumVelocity = 0;
+ }
+
mFullScreenMagnificationController = fullScreenMagnificationController;
mMagnificationInfoChangedCallback =
new FullScreenMagnificationController.MagnificationInfoChangedCallback() {
@@ -501,6 +526,7 @@
}
persistScaleAndTransitionTo(mViewportDraggingState);
} else if (action == ACTION_UP || action == ACTION_CANCEL) {
+ onPanningFinished(event);
// if feature flag is enabled, currently only true on watches
if (mIsSinglePanningEnabled) {
mOverscrollHandler.setScaleAndCenterToEdgeIfNeeded();
@@ -578,6 +604,7 @@
Slog.i(mLogTag, "Panned content by scrollX: " + distanceX
+ " scrollY: " + distanceY);
}
+ onPan(second);
mFullScreenMagnificationController.offsetMagnifiedRegion(mDisplayId, distanceX,
distanceY, AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
if (mIsSinglePanningEnabled) {
@@ -973,7 +1000,7 @@
&& overscrollState(event, mFirstPointerDownLocation)
== OVERSCROLL_VERTICAL_EDGE) {
transitionToDelegatingStateAndClear();
- }
+ } // TODO(b/319537921): should there be an else here?
//Primary pointer is swiping, so transit to PanningScalingState
transitToPanningScalingStateAndClear();
} else if (mIsSinglePanningEnabled
@@ -982,7 +1009,7 @@
if (overscrollState(event, mFirstPointerDownLocation)
== OVERSCROLL_VERTICAL_EDGE) {
transitionToDelegatingStateAndClear();
- }
+ } // TODO(b/319537921): should there be an else here?
transitToSinglePanningStateAndClear();
} else if (!mIsTwoFingerCountReached) {
// If it is a two-finger gesture, do not transition to the
@@ -1742,6 +1769,61 @@
}
}
+ /** Call during MOVE events for a panning gesture. */
+ private void onPan(MotionEvent event) {
+ if (!Flags.fullscreenFlingGesture()) {
+ return;
+ }
+
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+ mVelocityTracker.addMovement(event);
+ }
+
+ /**
+ * Call during UP events for a panning gesture, so we can detect a fling and play a physics-
+ * based fling animation.
+ */
+ private void onPanningFinished(MotionEvent event) {
+ if (!Flags.fullscreenFlingGesture()) {
+ return;
+ }
+
+ if (mVelocityTracker == null) {
+ Log.e(mLogTag, "onPanningFinished: mVelocityTracker is null");
+ return;
+ }
+ mVelocityTracker.addMovement(event);
+ mVelocityTracker.computeCurrentVelocity(/* units= */ 1000, mMaximumVelocity);
+
+ float xPixelsPerSecond = mVelocityTracker.getXVelocity();
+ float yPixelsPerSecond = mVelocityTracker.getYVelocity();
+
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+
+ if (DEBUG_PANNING_SCALING) {
+ Slog.v(
+ mLogTag,
+ "onPanningFinished: pixelsPerSecond: "
+ + xPixelsPerSecond
+ + ", "
+ + yPixelsPerSecond
+ + " mMinimumVelocity: "
+ + mMinimumVelocity);
+ }
+
+ if ((Math.abs(yPixelsPerSecond) > mMinimumVelocity)
+ || (Math.abs(xPixelsPerSecond) > mMinimumVelocity)) {
+ mFullScreenMagnificationController.startFling(
+ mDisplayId,
+ xPixelsPerSecond,
+ yPixelsPerSecond,
+ AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
+ }
+ }
+
final class SinglePanningState extends SimpleOnGestureListener implements State {
@@ -1756,6 +1838,8 @@
int action = event.getActionMasked();
switch (action) {
case ACTION_UP:
+ onPanningFinished(event);
+ // fall-through!
case ACTION_CANCEL:
mOverscrollHandler.setScaleAndCenterToEdgeIfNeeded();
mOverscrollHandler.clearEdgeState();
@@ -1770,6 +1854,7 @@
if (mCurrentState != mSinglePanningState) {
return true;
}
+ onPan(second);
mFullScreenMagnificationController.offsetMagnifiedRegion(
mDisplayId,
distanceX,