diff options
Diffstat (limited to 'java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java')
-rw-r--r-- | java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java | 496 |
1 files changed, 496 insertions, 0 deletions
diff --git a/java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java new file mode 100644 index 000000000..a21073d65 --- /dev/null +++ b/java/com/android/incallui/answer/impl/answermethod/FlingUpDownTouchHandler.java @@ -0,0 +1,496 @@ +/* + * Copyright (C) 2016 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.incallui.answer.impl.answermethod; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.annotation.SuppressLint; +import android.content.Context; +import android.support.annotation.FloatRange; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.View.OnTouchListener; +import android.view.ViewConfiguration; +import com.android.dialer.common.DpUtil; +import com.android.dialer.common.LogUtil; +import com.android.dialer.common.MathUtil; +import com.android.incallui.answer.impl.classifier.FalsingManager; +import com.android.incallui.answer.impl.utils.FlingAnimationUtils; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Touch handler that keeps track of flings for {@link FlingUpDownMethod}. */ +@SuppressLint("ClickableViewAccessibility") +class FlingUpDownTouchHandler implements OnTouchListener { + + /** Callback interface for significant events with this touch handler */ + interface OnProgressChangedListener { + + /** + * Called when the visible answer progress has changed. Implementations should use this for + * animation, but should not perform accepts or rejects until {@link #onMoveFinish(boolean)} is + * called. + * + * @param progress float representation of the progress with +1f fully accepted, -1f fully + * rejected, and 0 neutral. + */ + void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress); + + /** Called when a touch event has started being tracked. */ + void onTrackingStart(); + + /** Called when touch events stop being tracked. */ + void onTrackingStopped(); + + /** + * Called when the progress has fully animated back to neutral. Normal resting animation should + * resume, possibly with a hint animation first. + * + * @param showHint {@code true} iff the hint animation should be run before resuming normal + * animation. + */ + void onMoveReset(boolean showHint); + + /** + * Called when the progress has animated fully to accept or reject. + * + * @param accept {@code true} if the call has been accepted, {@code false} if it has been + * rejected. + */ + void onMoveFinish(boolean accept); + + /** + * Determine whether this gesture should use the {@link FalsingManager} to reject accidental + * touches + * + * @param downEvent the MotionEvent corresponding to the start of the gesture + * @return {@code true} if the {@link FalsingManager} should be used to reject accidental + * touches for this gesture + */ + boolean shouldUseFalsing(@NonNull MotionEvent downEvent); + } + + // Progress that must be moved through to not show the hint animation after gesture completes + private static final float HINT_MOVE_THRESHOLD_RATIO = .1f; + // Dp touch needs to move upward to be considered fully accepted + private static final int ACCEPT_THRESHOLD_DP = 150; + // Dp touch needs to move downward to be considered fully rejected + private static final int REJECT_THRESHOLD_DP = 150; + // Dp touch needs to move for it to not be considered a false touch (if FalsingManager is not + // enabled) + private static final int FALSING_THRESHOLD_DP = 40; + + // Progress at which a fling in the opposite direction will recenter instead of + // accepting/rejecting + private static final float PROGRESS_FLING_RECENTER = .1f; + + // Progress at which a slow swipe would continue toward accept/reject after the + // touch has been let go, otherwise will recenter + private static final float PROGRESS_SWIPE_RECENTER = .8f; + + private static final float REJECT_FLING_THRESHOLD_MODIFIER = 2f; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({FlingTarget.CENTER, FlingTarget.ACCEPT, FlingTarget.REJECT}) + private @interface FlingTarget { + int CENTER = 0; + int ACCEPT = 1; + int REJECT = -1; + } + + /** + * Create a new FlingUpDownTouchHandler and attach it to the target. Will call {@link + * View#setOnTouchListener(OnTouchListener)} before returning. + * + * @param target View whose touches are to be listened to + * @param listener Callback to listen to major events + * @param falsingManager FalsingManager to identify false touches + * @return the instance of FlingUpDownTouchHandler that has been added as a touch listener + */ + public static FlingUpDownTouchHandler attach( + @NonNull View target, + @NonNull OnProgressChangedListener listener, + @Nullable FalsingManager falsingManager) { + FlingUpDownTouchHandler handler = new FlingUpDownTouchHandler(target, listener, falsingManager); + target.setOnTouchListener(handler); + return handler; + } + + @NonNull private final View target; + @NonNull private final OnProgressChangedListener listener; + + private VelocityTracker velocityTracker; + private FlingAnimationUtils flingAnimationUtils; + + private boolean touchEnabled = true; + private boolean flingEnabled = true; + private float currentProgress; + private boolean tracking; + + private boolean motionAborted; + private boolean touchSlopExceeded; + private boolean hintDistanceExceeded; + private int trackingPointer; + private Animator progressAnimator; + + private float touchSlop; + private float initialTouchY; + private float acceptThresholdY; + private float rejectThresholdY; + private float zeroY; + + private boolean touchAboveFalsingThreshold; + private float falsingThresholdPx; + private boolean touchUsesFalsing; + + private final float acceptThresholdPx; + private final float rejectThresholdPx; + private final float deadZoneTopPx; + + @Nullable private final FalsingManager falsingManager; + + private FlingUpDownTouchHandler( + @NonNull View target, + @NonNull OnProgressChangedListener listener, + @Nullable FalsingManager falsingManager) { + this.target = target; + this.listener = listener; + Context context = target.getContext(); + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + flingAnimationUtils = new FlingAnimationUtils(context, .6f); + falsingThresholdPx = DpUtil.dpToPx(context, FALSING_THRESHOLD_DP); + acceptThresholdPx = DpUtil.dpToPx(context, ACCEPT_THRESHOLD_DP); + rejectThresholdPx = DpUtil.dpToPx(context, REJECT_THRESHOLD_DP); + + deadZoneTopPx = + Math.max( + context.getResources().getDimension(R.dimen.answer_swipe_dead_zone_top), + acceptThresholdPx); + this.falsingManager = falsingManager; + } + + /** Returns {@code true} iff a touch is being tracked */ + public boolean isTracking() { + return tracking; + } + + /** + * Sets whether touch events will continue to be listened to + * + * @param touchEnabled whether future touch events will be listened to + */ + public void setTouchEnabled(boolean touchEnabled) { + this.touchEnabled = touchEnabled; + } + + /** + * Sets whether fling velocity is used to affect accept/reject behavior + * + * @param flingEnabled whether fling velocity will be used when determining whether to + * accept/reject or recenter + */ + public void setFlingEnabled(boolean flingEnabled) { + this.flingEnabled = flingEnabled; + } + + public void detach() { + cancelProgressAnimator(); + setTouchEnabled(false); + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (falsingManager != null) { + falsingManager.onTouchEvent(event); + } + if (!touchEnabled) { + return false; + } + if (motionAborted && (event.getActionMasked() != MotionEvent.ACTION_DOWN)) { + return false; + } + + int pointerIndex = event.findPointerIndex(trackingPointer); + if (pointerIndex < 0) { + pointerIndex = 0; + trackingPointer = event.getPointerId(pointerIndex); + } + final float pointerY = event.getY(pointerIndex); + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + if (pointerY < deadZoneTopPx) { + return false; + } + motionAborted = false; + startMotion(pointerY, false, currentProgress); + touchAboveFalsingThreshold = false; + touchUsesFalsing = listener.shouldUseFalsing(event); + if (velocityTracker == null) { + initVelocityTracker(); + } + trackMovement(event); + cancelProgressAnimator(); + touchSlopExceeded = progressAnimator != null; + onTrackingStarted(); + break; + case MotionEvent.ACTION_POINTER_UP: + final int upPointer = event.getPointerId(event.getActionIndex()); + if (trackingPointer == upPointer) { + // gesture is ongoing, find a new pointer to track + int newIndex = event.getPointerId(0) != upPointer ? 0 : 1; + float newY = event.getY(newIndex); + trackingPointer = event.getPointerId(newIndex); + startMotion(newY, true, currentProgress); + } + break; + case MotionEvent.ACTION_POINTER_DOWN: + motionAborted = true; + endMotionEvent(event, pointerY, true); + return false; + case MotionEvent.ACTION_MOVE: + float deltaY = pointerY - initialTouchY; + + if (Math.abs(deltaY) > touchSlop) { + touchSlopExceeded = true; + } + if (Math.abs(deltaY) >= falsingThresholdPx) { + touchAboveFalsingThreshold = true; + } + setCurrentProgress(pointerYToProgress(pointerY)); + trackMovement(event); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + trackMovement(event); + endMotionEvent(event, pointerY, false); + } + return true; + } + + private void endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel) { + trackingPointer = -1; + if ((tracking && touchSlopExceeded) + || Math.abs(pointerY - initialTouchY) > touchSlop + || event.getActionMasked() == MotionEvent.ACTION_CANCEL + || forceCancel) { + float vel = 0f; + float vectorVel = 0f; + if (velocityTracker != null) { + velocityTracker.computeCurrentVelocity(1000); + vel = velocityTracker.getYVelocity(); + vectorVel = + Math.copySign( + (float) Math.hypot(velocityTracker.getXVelocity(), velocityTracker.getYVelocity()), + vel); + } + + boolean falseTouch = isFalseTouch(); + boolean forceRecenter = + falseTouch + || !touchSlopExceeded + || forceCancel + || event.getActionMasked() == MotionEvent.ACTION_CANCEL; + + @FlingTarget + int target = forceRecenter ? FlingTarget.CENTER : getFlingTarget(pointerY, vectorVel); + + fling(vel, target, falseTouch); + onTrackingStopped(); + } else { + onTrackingStopped(); + setCurrentProgress(0); + onMoveEnded(); + } + + if (velocityTracker != null) { + velocityTracker.recycle(); + velocityTracker = null; + } + } + + @FlingTarget + private int getFlingTarget(float pointerY, float vectorVel) { + float progress = pointerYToProgress(pointerY); + + float minVelocityPxPerSecond = flingAnimationUtils.getMinVelocityPxPerSecond(); + if (vectorVel > 0) { + minVelocityPxPerSecond *= REJECT_FLING_THRESHOLD_MODIFIER; + } + if (!flingEnabled || Math.abs(vectorVel) < minVelocityPxPerSecond) { + // Not a fling + if (Math.abs(progress) > PROGRESS_SWIPE_RECENTER) { + // Progress near one of the edges + return progress > 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; + } else { + return FlingTarget.CENTER; + } + } + + boolean sameDirection = vectorVel < 0 == progress > 0; + if (!sameDirection && Math.abs(progress) >= PROGRESS_FLING_RECENTER) { + // Being flung back toward center + return FlingTarget.CENTER; + } + // Flung toward an edge + return vectorVel < 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT; + } + + @FloatRange(from = -1f, to = 1f) + private float pointerYToProgress(float pointerY) { + boolean pointerAboveZero = pointerY > zeroY; + float nearestThreshold = pointerAboveZero ? rejectThresholdY : acceptThresholdY; + + float absoluteProgress = (pointerY - zeroY) / (nearestThreshold - zeroY); + return MathUtil.clamp(absoluteProgress * (pointerAboveZero ? -1 : 1), -1f, 1f); + } + + private boolean isFalseTouch() { + if (falsingManager != null && falsingManager.isEnabled()) { + if (falsingManager.isFalseTouch()) { + if (touchUsesFalsing) { + LogUtil.i("FlingUpDownTouchHandler.isFalseTouch", "rejecting false touch"); + return true; + } else { + LogUtil.i( + "FlingUpDownTouchHandler.isFalseTouch", + "Suspected false touch, but not using false touch rejection for this gesture"); + return false; + } + } else { + return false; + } + } + return !touchAboveFalsingThreshold; + } + + private void trackMovement(MotionEvent event) { + if (velocityTracker != null) { + velocityTracker.addMovement(event); + } + } + + private void fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing) { + ValueAnimator animator = createProgressAnimator(target); + if (target == FlingTarget.CENTER) { + flingAnimationUtils.apply(animator, currentProgress, target, velocity); + } else { + flingAnimationUtils.applyDismissing(animator, currentProgress, target, velocity, 1); + } + if (target == FlingTarget.CENTER && centerBecauseOfFalsing) { + velocity = 0; + } + if (velocity == 0) { + animator.setDuration(350); + } + + animator.addListener( + new AnimatorListenerAdapter() { + boolean canceled; + + @Override + public void onAnimationCancel(Animator animation) { + canceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + progressAnimator = null; + if (!canceled) { + onMoveEnded(); + } + } + }); + progressAnimator = animator; + animator.start(); + } + + private void onMoveEnded() { + if (currentProgress == 0) { + listener.onMoveReset(!hintDistanceExceeded); + } else { + listener.onMoveFinish(currentProgress > 0); + } + } + + private ValueAnimator createProgressAnimator(float targetProgress) { + ValueAnimator animator = ValueAnimator.ofFloat(currentProgress, targetProgress); + animator.addUpdateListener( + new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + setCurrentProgress((Float) animation.getAnimatedValue()); + } + }); + return animator; + } + + private void initVelocityTracker() { + if (velocityTracker != null) { + velocityTracker.recycle(); + } + velocityTracker = VelocityTracker.obtain(); + } + + private void startMotion(float newY, boolean startTracking, float startProgress) { + initialTouchY = newY; + hintDistanceExceeded = false; + + if (startProgress <= .25) { + acceptThresholdY = Math.max(0, initialTouchY - acceptThresholdPx); + rejectThresholdY = Math.min(target.getHeight(), initialTouchY + rejectThresholdPx); + zeroY = initialTouchY; + } + + if (startTracking) { + touchSlopExceeded = true; + onTrackingStarted(); + setCurrentProgress(startProgress); + } + } + + private void onTrackingStarted() { + tracking = true; + listener.onTrackingStart(); + } + + private void onTrackingStopped() { + tracking = false; + listener.onTrackingStopped(); + } + + private void cancelProgressAnimator() { + if (progressAnimator != null) { + progressAnimator.cancel(); + } + } + + private void setCurrentProgress(float progress) { + if (Math.abs(progress) > HINT_MOVE_THRESHOLD_RATIO) { + hintDistanceExceeded = true; + } + currentProgress = progress; + listener.onProgressChanged(progress); + } +} |