blob: b83e26e5de298a46e0e8e794a7bf584c4cc763a7 [file] [log] [blame]
/*
* Copyright (C) 2019 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.quickstep.util;
import android.content.Context;
import android.content.res.Resources;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import com.android.launcher3.Alarm;
import com.android.launcher3.R;
import com.android.launcher3.compat.AccessibilityManagerCompat;
import com.android.launcher3.testing.TestProtocol;
/**
* Given positions along x- or y-axis, tracks velocity and acceleration and determines when there is
* a pause in motion.
*/
public class MotionPauseDetector {
// The percentage of the previous speed that determines whether this is a rapid deceleration.
// The bigger this number, the easier it is to trigger the first pause.
private static final float RAPID_DECELERATION_FACTOR = 0.6f;
/** If no motion is added for this amount of time, assume the motion has paused. */
private static final long FORCE_PAUSE_TIMEOUT = 300;
/**
* After {@link #mMakePauseHarderToTrigger}, must move slowly for this long to trigger a pause.
*/
private static final long HARDER_TRIGGER_TIMEOUT = 400;
private final float mSpeedVerySlow;
private final float mSpeedSlow;
private final float mSpeedSomewhatFast;
private final float mSpeedFast;
private final Alarm mForcePauseTimeout;
private final boolean mMakePauseHarderToTrigger;
private final Context mContext;
private final SystemVelocityProvider mVelocityProvider;
private Float mPreviousVelocity = null;
private OnMotionPauseListener mOnMotionPauseListener;
private boolean mIsPaused;
// Bias more for the first pause to make it feel extra responsive.
private boolean mHasEverBeenPaused;
/** @see #setDisallowPause(boolean) */
private boolean mDisallowPause;
// Time at which speed became < mSpeedSlow (only used if mMakePauseHarderToTrigger == true).
private long mSlowStartTime;
public MotionPauseDetector(Context context) {
this(context, false);
}
/**
* @param makePauseHarderToTrigger Used for gestures that require a more explicit pause.
*/
public MotionPauseDetector(Context context, boolean makePauseHarderToTrigger) {
this(context, makePauseHarderToTrigger, MotionEvent.AXIS_Y);
}
/**
* @param makePauseHarderToTrigger Used for gestures that require a more explicit pause.
*/
public MotionPauseDetector(Context context, boolean makePauseHarderToTrigger, int axis) {
mContext = context;
Resources res = context.getResources();
mSpeedVerySlow = res.getDimension(R.dimen.motion_pause_detector_speed_very_slow);
mSpeedSlow = res.getDimension(R.dimen.motion_pause_detector_speed_slow);
mSpeedSomewhatFast = res.getDimension(R.dimen.motion_pause_detector_speed_somewhat_fast);
mSpeedFast = res.getDimension(R.dimen.motion_pause_detector_speed_fast);
mForcePauseTimeout = new Alarm();
mForcePauseTimeout.setOnAlarmListener(alarm -> updatePaused(true /* isPaused */));
mMakePauseHarderToTrigger = makePauseHarderToTrigger;
mVelocityProvider = new SystemVelocityProvider(axis);
}
/**
* Get callbacks for when motion pauses and resumes.
*/
public void setOnMotionPauseListener(OnMotionPauseListener listener) {
mOnMotionPauseListener = listener;
}
/**
* @param disallowPause If true, we will not detect any pauses until this is set to false again.
*/
public void setDisallowPause(boolean disallowPause) {
mDisallowPause = disallowPause;
updatePaused(mIsPaused);
}
/**
* Computes velocity and acceleration to determine whether the motion is paused.
* @param ev The motion being tracked.
*/
public void addPosition(MotionEvent ev) {
addPosition(ev, 0);
}
/**
* Computes velocity and acceleration to determine whether the motion is paused.
* @param ev The motion being tracked.
* @param pointerIndex Index for the pointer being tracked in the motion event
*/
public void addPosition(MotionEvent ev, int pointerIndex) {
long timeoutMs = TestProtocol.sForcePauseTimeout != null
? TestProtocol.sForcePauseTimeout
: mMakePauseHarderToTrigger ? HARDER_TRIGGER_TIMEOUT : FORCE_PAUSE_TIMEOUT;
mForcePauseTimeout.setAlarm(timeoutMs);
float newVelocity = mVelocityProvider.addMotionEvent(ev, ev.getPointerId(pointerIndex));
if (mPreviousVelocity != null) {
checkMotionPaused(newVelocity, mPreviousVelocity, ev.getEventTime());
}
mPreviousVelocity = newVelocity;
}
private void checkMotionPaused(float velocity, float prevVelocity, long time) {
float speed = Math.abs(velocity);
float previousSpeed = Math.abs(prevVelocity);
boolean isPaused;
if (mIsPaused) {
// Continue to be paused until moving at a fast speed.
isPaused = speed < mSpeedFast || previousSpeed < mSpeedFast;
} else {
if (velocity < 0 != prevVelocity < 0) {
// We're just changing directions, not necessarily stopping.
isPaused = false;
} else {
isPaused = speed < mSpeedVerySlow && previousSpeed < mSpeedVerySlow;
if (!isPaused && !mHasEverBeenPaused) {
// We want to be more aggressive about detecting the first pause to ensure it
// feels as responsive as possible; getting two very slow speeds back to back
// takes too long, so also check for a rapid deceleration.
boolean isRapidDeceleration = speed < previousSpeed * RAPID_DECELERATION_FACTOR;
isPaused = isRapidDeceleration && speed < mSpeedSomewhatFast;
}
if (mMakePauseHarderToTrigger) {
if (speed < mSpeedSlow) {
if (mSlowStartTime == 0) {
mSlowStartTime = time;
}
isPaused = time - mSlowStartTime >= HARDER_TRIGGER_TIMEOUT;
} else {
mSlowStartTime = 0;
isPaused = false;
}
}
}
}
updatePaused(isPaused);
}
private void updatePaused(boolean isPaused) {
if (mDisallowPause) {
isPaused = false;
}
if (mIsPaused != isPaused) {
mIsPaused = isPaused;
boolean isFirstDetectedPause = !mHasEverBeenPaused && mIsPaused;
if (mIsPaused) {
AccessibilityManagerCompat.sendPauseDetectedEventToTest(mContext);
mHasEverBeenPaused = true;
}
if (mOnMotionPauseListener != null) {
if (isFirstDetectedPause) {
mOnMotionPauseListener.onMotionPauseDetected();
}
// Null check again as onMotionPauseDetected() maybe have called clear().
if (mOnMotionPauseListener != null) {
mOnMotionPauseListener.onMotionPauseChanged(mIsPaused);
}
}
}
}
public void clear() {
mVelocityProvider.clear();
mPreviousVelocity = null;
setOnMotionPauseListener(null);
mIsPaused = mHasEverBeenPaused = false;
mSlowStartTime = 0;
mForcePauseTimeout.cancelAlarm();
}
public boolean isPaused() {
return mIsPaused;
}
public interface OnMotionPauseListener {
/** Called only the first time motion pause is detected. */
void onMotionPauseDetected();
/** Called every time motion changes from paused to not paused and vice versa. */
default void onMotionPauseChanged(boolean isPaused) { }
}
private static class SystemVelocityProvider {
private final VelocityTracker mVelocityTracker;
private final int mAxis;
SystemVelocityProvider(int axis) {
mVelocityTracker = VelocityTracker.obtain();
mAxis = axis;
}
/**
* Adds a new motion events, and returns the velocity at this point, or null if
* the velocity is not available
*/
public float addMotionEvent(MotionEvent ev, int pointer) {
mVelocityTracker.addMovement(ev);
mVelocityTracker.computeCurrentVelocity(1); // px / ms
return mAxis == MotionEvent.AXIS_X
? mVelocityTracker.getXVelocity(pointer)
: mVelocityTracker.getYVelocity(pointer);
}
/**
* Clears all stored motion event records
*/
public void clear() {
mVelocityTracker.clear();
}
}
}