/* * 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); } }