// Copyright 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.compositor.layouts.eventfilter; import android.annotation.SuppressLint; import android.content.Context; import android.os.SystemClock; import android.view.GestureDetector; import android.view.MotionEvent; import org.chromium.chrome.browser.tabmodel.TabModelSelector; import java.util.ArrayList; /** * An {@link EdgeSwipeEventFilter} triggers a edge swipe gesture or forward the events to its host * view. */ @SuppressLint("RtlHardcoded") public class EdgeSwipeEventFilter extends EventFilter { private static final boolean TAB_SWIPING_ENABLED = true; private static final long MAX_ACCUMULATE_DURATION_MS = 200; private static final double SIDE_SWIPE_ANGLE_THRESHOLD_DEGREES = 45; private static final double TAN_SIDE_SWIPE_ANGLE_THRESHOLD = Math.tan(Math.toRadians( SIDE_SWIPE_ANGLE_THRESHOLD_DEGREES)); // The distance that always initiate a side swipe. private static final float GUTTER_DISTANCE_DP = 6.4f; // The maximum distance the user can initiate a side swipe in. private static final float SWIPE_REGION_DP = 30; // The distance that an scroll event has to cover in Y to be marked as trustworthy. private static final float ACCUMULATE_THRESHOLD_DO = 12; // The divider constant for the exponential function used for side swipe. private static final double SWIPE_TIME_CONSTANT_DP = 30; public enum ScrollDirection { UNKNOWN, LEFT, RIGHT, DOWN, UP, } private boolean mEnableTabSwiping; private ScrollDirection mScrollDirection; private final double mSwipeTimeConstantPx; private final GestureDetector mGestureDetector; private TabModelSelector mTabModelSelector; private final EdgeSwipeHandler mEdgeSwipeHandler; private boolean mEdgeSwipeStarted; private boolean mInLongPress = false; private boolean mInDoubleTap = false; private boolean mScrollStarted; // This flag is used to for accumulating events when the motion at the beginning of a scroll // can not be trusted and we need more events to make a better angle and speed estimate. private boolean mAccumulatingEvents = false; private final ArrayList<MotionEvent> mAccumulatedEvents = new ArrayList<MotionEvent>(); private boolean mPropagateEventsToHostView; /** * Creates a {@link EdgeSwipeEventFilter} captures event either in edge swipe gestures or * propagate them. * @param context A {@link Context} instance. * @param host The {@link EventFilterHost} where the event is coming from. * @param edgeSwipeHandler The {@link EdgeSwipeHandler} that is going to get notified. */ public EdgeSwipeEventFilter( Context context, EventFilterHost host, EdgeSwipeHandler edgeSwipeHandler) { super(context, host, false); mEnableTabSwiping = TAB_SWIPING_ENABLED; mScrollDirection = ScrollDirection.UNKNOWN; mSwipeTimeConstantPx = SWIPE_TIME_CONSTANT_DP / mPxToDp; mGestureDetector = new GestureDetector(context, new ViewScrollerGestureDetector()); mGestureDetector.setIsLongpressEnabled(true); mEdgeSwipeHandler = edgeSwipeHandler; } /** * Enables or disables edge swiping on the device. This determines whether or not swipes that * originate from the edges of the screen are caught and handled by this object or not. * @param enable Whether or not to enable edge swiping. */ public void enableTabSwiping(boolean enable) { mEnableTabSwiping = TAB_SWIPING_ENABLED && enable; } /** * Sets the current {@link TabModelSelector}. */ public void setTabModelSelector(TabModelSelector tabModelSelector) { mTabModelSelector = tabModelSelector; } /** * Called whenever the beginning of a side swipe scroll is detected. This lets inheriting * classes trigger any side swipe behavior they want. * @param direction The direction the scroll can move. * @param x The horizontal coordinate the swipe started at in dp. * @param y The vertical coordinate the swipe started at in dp. */ private void scrollStarted(ScrollDirection direction, float x, float y) { if (mEdgeSwipeHandler != null) { mEdgeSwipeHandler.swipeStarted(direction, x, y); mEdgeSwipeStarted = true; } } /** * Called whenever the end of a side swipe scroll is detected. This lets inheriting classes * clean up any side swipe behavior they want. */ private void scrollFinished() { if (mEdgeSwipeHandler != null && mEdgeSwipeStarted) { mEdgeSwipeHandler.swipeFinished(); } mEdgeSwipeStarted = false; } /** * Called whenever a side swipe scroll event is triggered. This gives the new delta X that * represents how much scroll took place. * @param x The horizontal coordinate the swipe is currently at in dp. * @param y The vertical coordinate the swipe is currently at in dp. * @param dx The horizontal delta since the last update in dp. * @param dy The vertical delta since the last update in dp. * @param tx The total horizontal distance since the start of the scroll. * @param ty The total vertical distance since the start of the scroll. */ private void scrollUpdated(float x, float y, float dx, float dy, float tx, float ty) { if (mEdgeSwipeHandler != null && mEdgeSwipeStarted) { mEdgeSwipeHandler.swipeUpdated(x, y, dx, dy, tx, ty); } } /** * Called whenever a fling occurs on the container view. * @param x The horizontal coordinate the swipe is currently at in dp. * @param y The vertical coordinate the swipe is currently at in dp. * @param tx The total horizontal distance since the start of the scroll/fling. * @param ty The total vertical distance since the start of the scroll/fling. * @param vx The velocity in the X direction of the fling. * @param vy The velocity in the Y direction of the fling. */ private void flingOccurred(float x, float y, float tx, float ty, float vx, float vy) { if (mEdgeSwipeHandler != null && mEdgeSwipeStarted) { mEdgeSwipeHandler.swipeFlingOccurred(x, y, tx, ty, vx, vy); } } /** * @return Whether or not the user is currently side scrolling. */ protected boolean isSideScrolling() { return mScrollDirection == ScrollDirection.LEFT || mScrollDirection == ScrollDirection.RIGHT; } /** * @return Whether or not the user is currently down scrolling. */ protected boolean isDownScrolling() { return mScrollDirection == ScrollDirection.DOWN; } /** * Check whether the scroll event has a fast enough speed to trigger a side swipe. * It uses an exponential function to make it progressively harder to trigger side swipes * as the scroll start moves away from the edge of the screen. * @param e1 The DOWN event that started the scroll. * @param e2 The MOVE event that comes after the DOWN event. * @return Whether this scroll should initiate a side swipe. */ private boolean checkForFastScroll(MotionEvent e1, MotionEvent e2) { float dt = e2.getEventTime() - e1.getEventTime(); if (dt <= 0) return false; float dist; switch (mScrollDirection) { case RIGHT: dist = calculateBiasedPosition( e1.getX() + mCurrentTouchOffsetX, e2.getX() + mCurrentTouchOffsetX, dt); break; case LEFT: dist = mHost.getViewportWidth() * mPxToDp - calculateBiasedPosition( e1.getX() + mCurrentTouchOffsetX, e2.getX() + mCurrentTouchOffsetX, dt); break; case DOWN: dist = calculateBiasedPosition( e1.getY() + mCurrentTouchOffsetY, e2.getY() + mCurrentTouchOffsetY, dt); break; default: dist = GUTTER_DISTANCE_DP; break; } return dist < GUTTER_DISTANCE_DP; } private float calculateBiasedPosition(float p1, float p2, float dt) { assert dt > 0.f; float speed = Math.abs((p2 - p1) * mPxToDp / dt); float boost = (float) (Math.signum(p2 - p1) * (mSwipeTimeConstantPx * (Math.exp(speed) - 1.f))); return p1 * mPxToDp - boost; } /** * Propagates all collected touch events to the {@link EventFilterHost} once it is determined a * side swipe is not being performed. * @return Whether all the events were propagated successfully. */ private boolean propagateAccumulatedEventsAndClear() { boolean success = true; for (int i = 0; i < mAccumulatedEvents.size(); i++) { success = mHost.propagateEvent(mAccumulatedEvents.get(i)) && success; } mAccumulatedEvents.clear(); mAccumulatingEvents = false; return success; } @Override public boolean onInterceptTouchEventInternal(MotionEvent e, boolean isKeyboardShowing) { if (mTabModelSelector == null) return false; mPropagateEventsToHostView = true; mInLongPress = false; final int count = mTabModelSelector.getCurrentModel().getCount(); final int action = e.getActionMasked(); if (mEnableTabSwiping && !isKeyboardShowing && action == MotionEvent.ACTION_DOWN && count > 0 && mScrollDirection == ScrollDirection.UNKNOWN) { ScrollDirection direction = ScrollDirection.UNKNOWN; if ((e.getX() + mCurrentTouchOffsetX) * mPxToDp < SWIPE_REGION_DP) { direction = ScrollDirection.RIGHT; } else if (mHost.getViewportWidth() * mPxToDp - (e.getX() + mCurrentTouchOffsetX) * mPxToDp < SWIPE_REGION_DP) { direction = ScrollDirection.LEFT; } else if ((e.getY() + mCurrentTouchOffsetY) * mPxToDp < SWIPE_REGION_DP) { direction = ScrollDirection.DOWN; } // Check if we have a new direction and that it's supported. if (direction != ScrollDirection.UNKNOWN && (mEdgeSwipeHandler == null || mEdgeSwipeHandler.isSwipeEnabled(direction))) { mScrollDirection = direction; } if (mScrollDirection != ScrollDirection.UNKNOWN) mPropagateEventsToHostView = false; } return true; } @Override public boolean onTouchEventInternal(MotionEvent e) { if (mPropagateEventsToHostView) { mHost.propagateEvent(e); return true; } // If more than one pointer are down, we should forward these events to the // ContentView. final int action = e.getActionMasked(); if (action == MotionEvent.ACTION_POINTER_DOWN && e.getPointerCount() == 2 && !mScrollStarted) { mPropagateEventsToHostView = true; // Some type of multi-touch that should go to the view. mScrollDirection = ScrollDirection.UNKNOWN; MotionEvent cancelEvent = MotionEvent.obtain( e.getDownTime(), SystemClock.uptimeMillis(), MotionEvent.ACTION_CANCEL, 0, 0, 0); mGestureDetector.onTouchEvent(cancelEvent); propagateAccumulatedEventsAndClear(); mHost.propagateEvent(e); return true; } if (action == MotionEvent.ACTION_UP) { if (mInLongPress || mInDoubleTap) { mHost.propagateEvent(e); mInLongPress = false; mInDoubleTap = false; } } if (mScrollDirection != ScrollDirection.UNKNOWN) { if (mAccumulatingEvents) { mAccumulatedEvents.add(MotionEvent.obtain(e)); } mGestureDetector.onTouchEvent(e); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { scrollFinished(); propagateAccumulatedEventsAndClear(); mScrollDirection = ScrollDirection.UNKNOWN; } } return true; } /** * This class handles all the touch events and tries to direct them to the * right place. */ private class ViewScrollerGestureDetector extends GestureDetector.SimpleOnGestureListener { @Override public boolean onDown(MotionEvent e) { if (mInDoubleTap) { // If it is the 2nd ACTION_DOWN event for a double tap, forward the down event // to ContentView. return mHost.propagateEvent(e); } mAccumulatedEvents.add(MotionEvent.obtain(e)); mScrollStarted = false; return false; } @Override public boolean onSingleTapUp(MotionEvent e) { propagateAccumulatedEventsAndClear(); return mHost.propagateEvent(e); } @Override public boolean onDoubleTap(MotionEvent e) { // Double tap took place on the 2nd ACTION_DOWN event. It is called right before // onDown(). Set mInDoubleTap to true so that onDown() will forward the event. mInDoubleTap = true; return false; } @Override public void onLongPress(MotionEvent e) { // GestureDetectorProxy will correctly use the down time of this event to recognize // it as a long press. The up event should be also forwarded when received. propagateAccumulatedEventsAndClear(); mInLongPress = true; } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { final float x = e2.getX() * mPxToDp; final float y = e2.getY() * mPxToDp; final float dx = -distanceX * mPxToDp; final float dy = -distanceY * mPxToDp; final float tx = (e2.getRawX() - e1.getRawX()) * mPxToDp; final float ty = (e2.getRawY() - e1.getRawY()) * mPxToDp; if (mScrollStarted) { scrollUpdated(x, y, dx, dy, tx, ty); return true; } final boolean horizontal = mScrollDirection != ScrollDirection.DOWN; final float mainAxisAbsDelta = Math.abs(horizontal ? tx : ty); final float offAxisAbsDelta = Math.abs(horizontal ? ty : tx); if (mAccumulatingEvents) { final long lastAccumulatedEventTime = mAccumulatedEvents.get(mAccumulatedEvents.size() - 1).getEventTime(); final long firstAccumulatedEventTime = mAccumulatedEvents.get(0).getEventTime(); final long elapsedTime = lastAccumulatedEventTime - firstAccumulatedEventTime; // For deciding when to stop accumulating, we wait until we are out of a box // defined by where the scroll event started which is (e1.getRawX, e1.getRawY). if (offAxisAbsDelta < ACCUMULATE_THRESHOLD_DO && mainAxisAbsDelta < SWIPE_REGION_DP && elapsedTime <= MAX_ACCUMULATE_DURATION_MS) { return true; } } else { // mAccumulatingEvents false, so onTouch didn't record e2. mAccumulatedEvents.add(MotionEvent.obtain(e2)); } // We start accumulating events only if the current two events are not trustworthy, ie // they represent a tiny motion in both directions. e1 is always added in onDown(). // If mAccumulatingEvents was already true e2 was added in onTouch. if (!mAccumulatingEvents && offAxisAbsDelta < ACCUMULATE_THRESHOLD_DO && mainAxisAbsDelta < ACCUMULATE_THRESHOLD_DO) { mAccumulatingEvents = true; return true; } if (!checkForFastScroll(e1, e2) || offAxisAbsDelta > mainAxisAbsDelta * TAN_SIDE_SWIPE_ANGLE_THRESHOLD) { // Re-send the down event to the ContentView as it was cancelled during the // event intercepting. If we're in a long press the event was already forwarded. propagateAccumulatedEventsAndClear(); mScrollDirection = ScrollDirection.UNKNOWN; mPropagateEventsToHostView = true; } else { scrollStarted(mScrollDirection, x, y); mScrollStarted = true; mAccumulatedEvents.clear(); mAccumulatingEvents = false; } return true; } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { final float x = e2.getX() * mPxToDp; final float y = e2.getY() * mPxToDp; final float tx = (e2.getRawX() - e1.getRawX()) * mPxToDp; final float ty = (e2.getRawY() - e1.getRawY()) * mPxToDp; final float vx = velocityX * mPxToDp; final float vy = velocityY * mPxToDp; flingOccurred(x, y, tx, ty, vx, vy); return false; } } }