/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.gfx; import org.mozilla.gecko.GeckoAppShell; import org.mozilla.gecko.GeckoEvent; import org.mozilla.gecko.PrefsHelper; import org.mozilla.gecko.Tab; import org.mozilla.gecko.Tabs; import org.mozilla.gecko.ZoomConstraints; import org.mozilla.gecko.EventDispatcher; import org.mozilla.gecko.util.FloatUtils; import org.mozilla.gecko.util.GamepadUtils; import org.mozilla.gecko.util.GeckoEventListener; import org.mozilla.gecko.util.ThreadUtils; import org.json.JSONObject; import android.graphics.PointF; import android.graphics.RectF; import android.os.Build; import android.util.FloatMath; import android.util.Log; import android.view.GestureDetector; import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; /* * Handles the kinetic scrolling and zooming physics for a layer controller. * * Many ideas are from Joe Hewitt's Scrollability: * https://github.com/joehewitt/scrollability/ */ class JavaPanZoomController extends GestureDetector.SimpleOnGestureListener implements PanZoomController, SimpleScaleGestureDetector.SimpleScaleGestureListener, GeckoEventListener { private static final String LOGTAG = "GeckoPanZoomController"; private static String MESSAGE_ZOOM_RECT = "Browser:ZoomToRect"; private static String MESSAGE_ZOOM_PAGE = "Browser:ZoomToPageWidth"; private static String MESSAGE_TOUCH_LISTENER = "Tab:HasTouchListener"; // Animation stops if the velocity is below this value when overscrolled or panning. private static final float STOPPED_THRESHOLD = 4.0f; // Animation stops is the velocity is below this threshold when flinging. private static final float FLING_STOPPED_THRESHOLD = 0.1f; // The distance the user has to pan before we recognize it as such (e.g. to avoid 1-pixel pans // between the touch-down and touch-up of a click). In units of density-independent pixels. public static final float PAN_THRESHOLD = 1/16f * GeckoAppShell.getDpi(); // Angle from axis within which we stay axis-locked private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees // Axis-lock breakout angle private static final double AXIS_BREAKOUT_ANGLE = Math.PI / 8.0; // The distance the user has to pan before we consider breaking out of a locked axis public static final float AXIS_BREAKOUT_THRESHOLD = 1/32f * GeckoAppShell.getDpi(); // The maximum amount we allow you to zoom into a page private static final float MAX_ZOOM = 4.0f; // The maximum amount we would like to scroll with the mouse private static final float MAX_SCROLL = 0.075f * GeckoAppShell.getDpi(); // The maximum zoom factor adjustment per frame of the AUTONAV animation private static final float MAX_ZOOM_DELTA = 0.125f; // The duration of the bounce animation in ns private static final int BOUNCE_ANIMATION_DURATION = 250000000; private enum PanZoomState { NOTHING, /* no touch-start events received */ FLING, /* all touches removed, but we're still scrolling page */ TOUCHING, /* one touch-start event received */ PANNING_LOCKED_X, /* touch-start followed by move (i.e. panning with axis lock) X axis */ PANNING_LOCKED_Y, /* as above for Y axis */ PANNING, /* panning without axis lock */ PANNING_HOLD, /* in panning, but not moving. * similar to TOUCHING but after starting a pan */ PANNING_HOLD_LOCKED_X, /* like PANNING_HOLD, but axis lock still in effect for X axis */ PANNING_HOLD_LOCKED_Y, /* as above but for Y axis */ PINCHING, /* nth touch-start, where n > 1. this mode allows pan and zoom */ ANIMATED_ZOOM, /* animated zoom to a new rect */ BOUNCE, /* in a bounce animation */ WAITING_LISTENERS, /* a state halfway between NOTHING and TOUCHING - the user has put a finger down, but we don't yet know if a touch listener has prevented the default actions yet. we still need to abort animations. */ AUTONAV, /* We are scrolling using an AutonavRunnable animation. This is similar to the FLING state except that it must be stopped manually by the code that started it, and it's velocity can be updated while it's running. */ } private enum AxisLockMode { STANDARD, /* Default axis locking mode that doesn't break out until finger release */ FREE, /* No locking at all */ STICKY /* Break out with hysteresis so that it feels as free as possible whilst locking */ } private final PanZoomTarget mTarget; private final SubdocumentScrollHelper mSubscroller; private final Axis mX; private final Axis mY; private final TouchEventHandler mTouchEventHandler; private final EventDispatcher mEventDispatcher; /* The task that handles flings, autonav or bounces. */ private PanZoomRenderTask mAnimationRenderTask; /* The zoom focus at the first zoom event (in page coordinates). */ private PointF mLastZoomFocus; /* The time the last motion event took place. */ private long mLastEventTime; /* Current state the pan/zoom UI is in. */ private PanZoomState mState; /* The per-frame zoom delta for the currently-running AUTONAV animation. */ private float mAutonavZoomDelta; /* The user selected panning mode */ private AxisLockMode mMode; /* A medium-length tap/press is happening */ private boolean mMediumPress; /* Used to change the scrollY direction */ private boolean mNegateWheelScrollY; /* Whether the current event has been default-prevented. */ private boolean mDefaultPrevented; // Handler to be notified when overscroll occurs private Overscroll mOverscroll; public JavaPanZoomController(PanZoomTarget target, View view, EventDispatcher eventDispatcher) { mTarget = target; mSubscroller = new SubdocumentScrollHelper(eventDispatcher); mX = new AxisX(mSubscroller); mY = new AxisY(mSubscroller); mTouchEventHandler = new TouchEventHandler(view.getContext(), view, this); checkMainThread(); setState(PanZoomState.NOTHING); mEventDispatcher = eventDispatcher; registerEventListener(MESSAGE_ZOOM_RECT); registerEventListener(MESSAGE_ZOOM_PAGE); registerEventListener(MESSAGE_TOUCH_LISTENER); mMode = AxisLockMode.STANDARD; String[] prefs = { "ui.scrolling.axis_lock_mode", "ui.scrolling.negate_wheel_scrollY", "ui.scrolling.gamepad_dead_zone" }; mNegateWheelScrollY = false; PrefsHelper.getPrefs(prefs, new PrefsHelper.PrefHandlerBase() { @Override public void prefValue(String pref, String value) { if (pref.equals("ui.scrolling.axis_lock_mode")) { if (value.equals("standard")) { mMode = AxisLockMode.STANDARD; } else if (value.equals("free")) { mMode = AxisLockMode.FREE; } else { mMode = AxisLockMode.STICKY; } } } @Override public void prefValue(String pref, int value) { if (pref.equals("ui.scrolling.gamepad_dead_zone")) { GamepadUtils.overrideDeadZoneThreshold((float)value / 1000f); } } @Override public void prefValue(String pref, boolean value) { if (pref.equals("ui.scrolling.negate_wheel_scrollY")) { mNegateWheelScrollY = value; } } @Override public boolean isObserver() { return true; } }); Axis.initPrefs(); } @Override public void destroy() { unregisterEventListener(MESSAGE_ZOOM_RECT); unregisterEventListener(MESSAGE_ZOOM_PAGE); unregisterEventListener(MESSAGE_TOUCH_LISTENER); mSubscroller.destroy(); mTouchEventHandler.destroy(); } private final static float easeOut(float t) { // ease-out approx. // -(t-1)^2+1 t = t-1; return -t*t+1; } private void registerEventListener(String event) { mEventDispatcher.registerEventListener(event, this); } private void unregisterEventListener(String event) { mEventDispatcher.unregisterEventListener(event, this); } private void setState(PanZoomState state) { if (state != mState) { GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("PanZoom:StateChange", state.toString())); mState = state; // Let the target know we've finished with it (for now) if (state == PanZoomState.NOTHING) { mTarget.panZoomStopped(); } } } private ImmutableViewportMetrics getMetrics() { return mTarget.getViewportMetrics(); } private void checkMainThread() { if (!ThreadUtils.isOnUiThread()) { // log with full stack trace Log.e(LOGTAG, "Uh-oh, we're running on the wrong thread!", new Exception()); } } @Override public void handleMessage(String event, JSONObject message) { try { if (MESSAGE_ZOOM_RECT.equals(event)) { float x = (float)message.getDouble("x"); float y = (float)message.getDouble("y"); final RectF zoomRect = new RectF(x, y, x + (float)message.getDouble("w"), y + (float)message.getDouble("h")); if (message.optBoolean("animate", true)) { mTarget.post(new Runnable() { @Override public void run() { animatedZoomTo(zoomRect); } }); } else { mTarget.setViewportMetrics(getMetricsToZoomTo(zoomRect)); } } else if (MESSAGE_ZOOM_PAGE.equals(event)) { ImmutableViewportMetrics metrics = getMetrics(); RectF cssPageRect = metrics.getCssPageRect(); RectF viewableRect = metrics.getCssViewport(); float y = viewableRect.top; // attempt to keep zoom keep focused on the center of the viewport float newHeight = viewableRect.height() * cssPageRect.width() / viewableRect.width(); float dh = viewableRect.height() - newHeight; // increase in the height final RectF r = new RectF(0.0f, y + dh/2, cssPageRect.width(), y + dh/2 + newHeight); if (message.optBoolean("animate", true)) { mTarget.post(new Runnable() { @Override public void run() { animatedZoomTo(r); } }); } else { mTarget.setViewportMetrics(getMetricsToZoomTo(r)); } } else if (MESSAGE_TOUCH_LISTENER.equals(event)) { int tabId = message.getInt("tabID"); final Tab tab = Tabs.getInstance().getTab(tabId); tab.setHasTouchListeners(true); mTarget.post(new Runnable() { @Override public void run() { if (Tabs.getInstance().isSelectedTab(tab)) mTouchEventHandler.setWaitForTouchListeners(true); } }); } } catch (Exception e) { Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); } } /** This function MUST be called on the UI thread */ @Override public boolean onKeyEvent(KeyEvent event) { if (Build.VERSION.SDK_INT <= 11) { return false; } if ((event.getSource() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD && event.getAction() == KeyEvent.ACTION_DOWN) { switch (event.getKeyCode()) { case KeyEvent.KEYCODE_ZOOM_IN: return animatedScale(0.2f); case KeyEvent.KEYCODE_ZOOM_OUT: return animatedScale(-0.2f); } } return false; } /** This function MUST be called on the UI thread */ @Override public boolean onMotionEvent(MotionEvent event) { if (Build.VERSION.SDK_INT <= 11) { return false; } switch (event.getSource() & InputDevice.SOURCE_CLASS_MASK) { case InputDevice.SOURCE_CLASS_POINTER: switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_SCROLL: return handlePointerScroll(event); } break; case InputDevice.SOURCE_CLASS_JOYSTICK: switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_MOVE: return handleJoystickNav(event); } break; } return false; } /** This function MUST be called on the UI thread */ @Override public boolean onTouchEvent(MotionEvent event) { return mTouchEventHandler.handleEvent(event); } boolean handleEvent(MotionEvent event, boolean defaultPrevented) { mDefaultPrevented = defaultPrevented; switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: return handleTouchStart(event); case MotionEvent.ACTION_MOVE: return handleTouchMove(event); case MotionEvent.ACTION_UP: return handleTouchEnd(event); case MotionEvent.ACTION_CANCEL: return handleTouchCancel(event); } return false; } /** This function MUST be called on the UI thread */ @Override public void notifyDefaultActionPrevented(boolean prevented) { mTouchEventHandler.handleEventListenerAction(!prevented); } /** This function must be called from the UI thread. */ @Override public void abortAnimation() { checkMainThread(); // this happens when gecko changes the viewport on us or if the device is rotated. // if that's the case, abort any animation in progress and re-zoom so that the page // snaps to edges. for other cases (where the user's finger(s) are down) don't do // anything special. switch (mState) { case FLING: mX.stopFling(); mY.stopFling(); // fall through case BOUNCE: case ANIMATED_ZOOM: // the zoom that's in progress likely makes no sense any more (such as if // the screen orientation changed) so abort it setState(PanZoomState.NOTHING); // fall through case NOTHING: // Don't do animations here; they're distracting and can cause flashes on page // transitions. synchronized (mTarget.getLock()) { mTarget.setViewportMetrics(getValidViewportMetrics()); mTarget.forceRedraw(null); } break; } } /** This function must be called on the UI thread. */ public void startingNewEventBlock(MotionEvent event, boolean waitingForTouchListeners) { checkMainThread(); mSubscroller.cancel(); if (waitingForTouchListeners && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) { // this is the first touch point going down, so we enter the pending state // seting the state will kill any animations in progress, possibly leaving // the page in overscroll setState(PanZoomState.WAITING_LISTENERS); } } /** This must be called on the UI thread. */ @Override public void pageRectUpdated() { if (mState == PanZoomState.NOTHING) { synchronized (mTarget.getLock()) { ImmutableViewportMetrics validated = getValidViewportMetrics(); if (!getMetrics().fuzzyEquals(validated)) { // page size changed such that we are now in overscroll. snap to the // the nearest valid viewport mTarget.setViewportMetrics(validated); } } } } /* * Panning/scrolling */ private boolean handleTouchStart(MotionEvent event) { // user is taking control of movement, so stop // any auto-movement we have going stopAnimationTask(); switch (mState) { case ANIMATED_ZOOM: // We just interrupted a double-tap animation, so force a redraw in // case this touchstart is just a tap that doesn't end up triggering // a redraw mTarget.forceRedraw(null); // fall through case FLING: case AUTONAV: case BOUNCE: case NOTHING: case WAITING_LISTENERS: startTouch(event.getX(0), event.getY(0), event.getEventTime()); return false; case TOUCHING: case PANNING: case PANNING_LOCKED_X: case PANNING_LOCKED_Y: case PANNING_HOLD: case PANNING_HOLD_LOCKED_X: case PANNING_HOLD_LOCKED_Y: case PINCHING: Log.e(LOGTAG, "Received impossible touch down while in " + mState); return false; } Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchStart"); return false; } private boolean handleTouchMove(MotionEvent event) { switch (mState) { case FLING: case AUTONAV: case BOUNCE: case WAITING_LISTENERS: // should never happen Log.e(LOGTAG, "Received impossible touch move while in " + mState); // fall through case ANIMATED_ZOOM: case NOTHING: // may happen if user double-taps and drags without lifting after the // second tap. ignore the move if this happens. return false; case TOUCHING: // Don't allow panning if there is an element in full-screen mode. See bug 775511. if ((mTarget.isFullScreen() && !mSubscroller.scrolling()) || panDistance(event) < PAN_THRESHOLD) { return false; } cancelTouch(); startPanning(event.getX(0), event.getY(0), event.getEventTime()); track(event); return true; case PANNING_HOLD_LOCKED_X: setState(PanZoomState.PANNING_LOCKED_X); track(event); return true; case PANNING_HOLD_LOCKED_Y: setState(PanZoomState.PANNING_LOCKED_Y); // fall through case PANNING_LOCKED_X: case PANNING_LOCKED_Y: track(event); return true; case PANNING_HOLD: setState(PanZoomState.PANNING); // fall through case PANNING: track(event); return true; case PINCHING: // scale gesture listener will handle this return false; } Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchMove"); return false; } private boolean handleTouchEnd(MotionEvent event) { switch (mState) { case FLING: case AUTONAV: case BOUNCE: case ANIMATED_ZOOM: case NOTHING: // may happen if user double-taps and drags without lifting after the // second tap. ignore if this happens. return false; case WAITING_LISTENERS: if (!mDefaultPrevented) { // should never happen Log.e(LOGTAG, "Received impossible touch end while in " + mState); } // fall through case TOUCHING: // the switch into TOUCHING might have happened while the page was // snapping back after overscroll. we need to finish the snap if that // was the case bounce(); return false; case PANNING: case PANNING_LOCKED_X: case PANNING_LOCKED_Y: case PANNING_HOLD: case PANNING_HOLD_LOCKED_X: case PANNING_HOLD_LOCKED_Y: setState(PanZoomState.FLING); fling(); return true; case PINCHING: setState(PanZoomState.NOTHING); return true; } Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchEnd"); return false; } private boolean handleTouchCancel(MotionEvent event) { cancelTouch(); // ensure we snap back if we're overscrolled bounce(); return false; } private boolean handlePointerScroll(MotionEvent event) { if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) { float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL); float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL); if (mNegateWheelScrollY) { scrollY *= -1.0; } scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL); bounce(); return true; } return false; } private float filterDeadZone(MotionEvent event, int axis) { return (GamepadUtils.isValueInDeadZone(event, axis) ? 0 : event.getAxisValue(axis)); } private float normalizeJoystickScroll(MotionEvent event, int axis) { return filterDeadZone(event, axis) * MAX_SCROLL; } private float normalizeJoystickZoom(MotionEvent event, int axis) { // negate MAX_ZOOM_DELTA so that pushing up on the stick zooms in return filterDeadZone(event, axis) * -MAX_ZOOM_DELTA; } // Since this event is a position-based event rather than a motion-based event, we need to // set up an AUTONAV animation to keep scrolling even while we don't get events. private boolean handleJoystickNav(MotionEvent event) { float velocityX = normalizeJoystickScroll(event, MotionEvent.AXIS_X); float velocityY = normalizeJoystickScroll(event, MotionEvent.AXIS_Y); float zoomDelta = normalizeJoystickZoom(event, MotionEvent.AXIS_RZ); if (velocityX == 0 && velocityY == 0 && zoomDelta == 0) { if (mState == PanZoomState.AUTONAV) { bounce(); // if not needed, this will automatically go to state NOTHING return true; } return false; } if (mState == PanZoomState.NOTHING) { setState(PanZoomState.AUTONAV); startAnimationRenderTask(new AutonavRenderTask()); } if (mState == PanZoomState.AUTONAV) { mX.setAutoscrollVelocity(velocityX); mY.setAutoscrollVelocity(velocityY); mAutonavZoomDelta = zoomDelta; return true; } return false; } private void startTouch(float x, float y, long time) { mX.startTouch(x); mY.startTouch(y); setState(PanZoomState.TOUCHING); mLastEventTime = time; } private void startPanning(float x, float y, long time) { float dx = mX.panDistance(x); float dy = mY.panDistance(y); double angle = Math.atan2(dy, dx); // range [-pi, pi] angle = Math.abs(angle); // range [0, pi] // When the touch move breaks through the pan threshold, reposition the touch down origin // so the page won't jump when we start panning. mX.startTouch(x); mY.startTouch(y); mLastEventTime = time; if (mMode == AxisLockMode.STANDARD || mMode == AxisLockMode.STICKY) { if (!mX.scrollable() || !mY.scrollable()) { setState(PanZoomState.PANNING); } else if (angle < AXIS_LOCK_ANGLE || angle > (Math.PI - AXIS_LOCK_ANGLE)) { mY.setScrollingDisabled(true); setState(PanZoomState.PANNING_LOCKED_X); } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) { mX.setScrollingDisabled(true); setState(PanZoomState.PANNING_LOCKED_Y); } else { setState(PanZoomState.PANNING); } } else if (mMode == AxisLockMode.FREE) { setState(PanZoomState.PANNING); } } private float panDistance(MotionEvent move) { float dx = mX.panDistance(move.getX(0)); float dy = mY.panDistance(move.getY(0)); return FloatMath.sqrt(dx * dx + dy * dy); } private void track(float x, float y, long time) { float timeDelta = (float)(time - mLastEventTime); if (FloatUtils.fuzzyEquals(timeDelta, 0)) { // probably a duplicate event, ignore it. using a zero timeDelta will mess // up our velocity return; } mLastEventTime = time; // if we're axis-locked check if the user is trying to scroll away from the lock if (mMode == AxisLockMode.STICKY) { float dx = mX.panDistance(x); float dy = mY.panDistance(y); double angle = Math.atan2(dy, dx); // range [-pi, pi] angle = Math.abs(angle); // range [0, pi] if (Math.abs(dx) > AXIS_BREAKOUT_THRESHOLD || Math.abs(dy) > AXIS_BREAKOUT_THRESHOLD) { if (mState == PanZoomState.PANNING_LOCKED_X) { if (angle > AXIS_BREAKOUT_ANGLE && angle < (Math.PI - AXIS_BREAKOUT_ANGLE)) { mY.setScrollingDisabled(false); setState(PanZoomState.PANNING); } } else if (mState == PanZoomState.PANNING_LOCKED_Y) { if (Math.abs(angle - (Math.PI / 2)) > AXIS_BREAKOUT_ANGLE) { mX.setScrollingDisabled(false); setState(PanZoomState.PANNING); } } } } mX.updateWithTouchAt(x, timeDelta); mY.updateWithTouchAt(y, timeDelta); } private void track(MotionEvent event) { mX.saveTouchPos(); mY.saveTouchPos(); for (int i = 0; i < event.getHistorySize(); i++) { track(event.getHistoricalX(0, i), event.getHistoricalY(0, i), event.getHistoricalEventTime(i)); } track(event.getX(0), event.getY(0), event.getEventTime()); if (stopped()) { if (mState == PanZoomState.PANNING) { setState(PanZoomState.PANNING_HOLD); } else if (mState == PanZoomState.PANNING_LOCKED_X) { setState(PanZoomState.PANNING_HOLD_LOCKED_X); } else if (mState == PanZoomState.PANNING_LOCKED_Y) { setState(PanZoomState.PANNING_HOLD_LOCKED_Y); } else { // should never happen, but handle anyway for robustness Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track"); setState(PanZoomState.PANNING_HOLD); } } mX.startPan(); mY.startPan(); updatePosition(); } private void scrollBy(float dx, float dy) { mTarget.scrollBy(dx, dy); } private void fling() { updatePosition(); stopAnimationTask(); boolean stopped = stopped(); mX.startFling(stopped); mY.startFling(stopped); startAnimationRenderTask(new FlingRenderTask()); } /* Performs a bounce-back animation to the given viewport metrics. */ private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) { stopAnimationTask(); ImmutableViewportMetrics bounceStartMetrics = getMetrics(); if (bounceStartMetrics.fuzzyEquals(metrics)) { setState(PanZoomState.NOTHING); return; } setState(state); // At this point we have already set mState to BOUNCE or ANIMATED_ZOOM, so // getRedrawHint() is returning false. This means we can safely call // setAnimationTarget to set the new final display port and not have it get // clobbered by display ports from intermediate animation frames. mTarget.setAnimationTarget(metrics); startAnimationRenderTask(new BounceRenderTask(bounceStartMetrics, metrics)); } /* Performs a bounce-back animation to the nearest valid viewport metrics. */ private void bounce() { bounce(getValidViewportMetrics(), PanZoomState.BOUNCE); } /* Starts the fling or bounce animation. */ private void startAnimationRenderTask(final PanZoomRenderTask task) { if (mAnimationRenderTask != null) { Log.e(LOGTAG, "Attempted to start a new task without canceling the old one!"); stopAnimationTask(); } mAnimationRenderTask = task; mTarget.postRenderTask(mAnimationRenderTask); } /* Stops the fling or bounce animation. */ private void stopAnimationTask() { if (mAnimationRenderTask != null) { mAnimationRenderTask.terminate(); mTarget.removeRenderTask(mAnimationRenderTask); mAnimationRenderTask = null; } } private float getVelocity() { float xvel = mX.getRealVelocity(); float yvel = mY.getRealVelocity(); return FloatMath.sqrt(xvel * xvel + yvel * yvel); } @Override public PointF getVelocityVector() { return new PointF(mX.getRealVelocity(), mY.getRealVelocity()); } private boolean stopped() { return getVelocity() < STOPPED_THRESHOLD; } PointF resetDisplacement() { return new PointF(mX.resetDisplacement(), mY.resetDisplacement()); } private void updatePosition() { mX.displace(); mY.displace(); PointF displacement = resetDisplacement(); if (FloatUtils.fuzzyEquals(displacement.x, 0.0f) && FloatUtils.fuzzyEquals(displacement.y, 0.0f)) { return; } if (mDefaultPrevented || mSubscroller.scrollBy(displacement)) { synchronized (mTarget.getLock()) { mTarget.scrollMarginsBy(displacement.x, displacement.y); } } else { synchronized (mTarget.getLock()) { scrollBy(displacement.x, displacement.y); } } } /** * This class is an implementation of RenderTask which enforces its implementor to run in the UI thread. * */ private abstract class PanZoomRenderTask extends RenderTask { /** * the time when the current frame was started in ns. */ protected long mCurrentFrameStartTime; /** * The current frame duration in ns. */ protected long mLastFrameTimeDelta; private final Runnable mRunnable = new Runnable() { @Override public final void run() { if (mContinueAnimation) { animateFrame(); } } }; private boolean mContinueAnimation = true; public PanZoomRenderTask() { super(false); } @Override protected final boolean internalRun(long timeDelta, long currentFrameStartTime) { mCurrentFrameStartTime = currentFrameStartTime; mLastFrameTimeDelta = timeDelta; mTarget.post(mRunnable); return mContinueAnimation; } /** * The method subclasses must override. This method is run on the UI thread thanks to internalRun */ protected abstract void animateFrame(); /** * Terminate the animation. */ public void terminate() { mContinueAnimation = false; } } private class AutonavRenderTask extends PanZoomRenderTask { public AutonavRenderTask() { super(); } @Override protected void animateFrame() { if (mState != PanZoomState.AUTONAV) { finishAnimation(); return; } updatePosition(); synchronized (mTarget.getLock()) { mTarget.setViewportMetrics(applyZoomDelta(getMetrics(), mAutonavZoomDelta)); } } } /* The task that performs the bounce animation. */ private class BounceRenderTask extends PanZoomRenderTask { /* * The viewport metrics that represent the start and end of the bounce-back animation, * respectively. */ private ImmutableViewportMetrics mBounceStartMetrics; private ImmutableViewportMetrics mBounceEndMetrics; // How long ago this bounce was started in ns. private long mBounceDuration; BounceRenderTask(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) { super(); mBounceStartMetrics = startMetrics; mBounceEndMetrics = endMetrics; } @Override protected void animateFrame() { /* * The pan/zoom controller might have signaled to us that it wants to abort the * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail * out. */ if (!(mState == PanZoomState.BOUNCE || mState == PanZoomState.ANIMATED_ZOOM)) { finishAnimation(); return; } /* Perform the next frame of the bounce-back animation. */ mBounceDuration = mCurrentFrameStartTime - getStartTime(); if (mBounceDuration < BOUNCE_ANIMATION_DURATION) { advanceBounce(); return; } /* Finally, if there's nothing else to do, complete the animation and go to sleep. */ finishBounce(); finishAnimation(); setState(PanZoomState.NOTHING); } /* Performs one frame of a bounce animation. */ private void advanceBounce() { synchronized (mTarget.getLock()) { float t = easeOut((float)mBounceDuration / BOUNCE_ANIMATION_DURATION); ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t); mTarget.setViewportMetrics(newMetrics); } } /* Concludes a bounce animation and snaps the viewport into place. */ private void finishBounce() { synchronized (mTarget.getLock()) { mTarget.setViewportMetrics(mBounceEndMetrics); } } } // The callback that performs the fling animation. private class FlingRenderTask extends PanZoomRenderTask { public FlingRenderTask() { super(); } @Override protected void animateFrame() { /* * The pan/zoom controller might have signaled to us that it wants to abort the * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail * out. */ if (mState != PanZoomState.FLING) { finishAnimation(); return; } /* Advance flings, if necessary. */ boolean flingingX = mX.advanceFling(mLastFrameTimeDelta); boolean flingingY = mY.advanceFling(mLastFrameTimeDelta); boolean overscrolled = (mX.overscrolled() || mY.overscrolled()); /* If we're still flinging in any direction, update the origin. */ if (flingingX || flingingY) { updatePosition(); /* * Check to see if we're still flinging with an appreciable velocity. The threshold is * higher in the case of overscroll, so we bounce back eagerly when overscrolling but * coast smoothly to a stop when not. In other words, require a greater velocity to * maintain the fling once we enter overscroll. */ float threshold = (overscrolled && !mSubscroller.scrolling() ? STOPPED_THRESHOLD : FLING_STOPPED_THRESHOLD); if (getVelocity() >= threshold) { // we're still flinging return; } mX.stopFling(); mY.stopFling(); } /* Perform a bounce-back animation if overscrolled. */ if (overscrolled) { bounce(); } else { finishAnimation(); setState(PanZoomState.NOTHING); } } } private void finishAnimation() { checkMainThread(); stopAnimationTask(); // Force a viewport synchronisation mTarget.forceRedraw(null); } /* Returns the nearest viewport metrics with no overscroll visible. */ private ImmutableViewportMetrics getValidViewportMetrics() { return getValidViewportMetrics(getMetrics()); } private ImmutableViewportMetrics getValidViewportMetrics(ImmutableViewportMetrics viewportMetrics) { /* First, we adjust the zoom factor so that we can make no overscrolled area visible. */ float zoomFactor = viewportMetrics.zoomFactor; RectF pageRect = viewportMetrics.getPageRect(); RectF viewport = viewportMetrics.getViewport(); float focusX = viewport.width() / 2.0f; float focusY = viewport.height() / 2.0f; float minZoomFactor = 0.0f; float maxZoomFactor = MAX_ZOOM; ZoomConstraints constraints = mTarget.getZoomConstraints(); if (constraints.getMinZoom() > 0) minZoomFactor = constraints.getMinZoom(); if (constraints.getMaxZoom() > 0) maxZoomFactor = constraints.getMaxZoom(); if (!constraints.getAllowZoom()) { // If allowZoom is false, clamp to the default zoom level. maxZoomFactor = minZoomFactor = constraints.getDefaultZoom(); } // Ensure minZoomFactor keeps the page at least as big as the viewport. if (pageRect.width() > 0) { float pageWidth = pageRect.width() + viewportMetrics.marginLeft + viewportMetrics.marginRight; float scaleFactor = viewport.width() / pageWidth; minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor); if (viewport.width() > pageWidth) focusX = 0.0f; } if (pageRect.height() > 0) { float pageHeight = pageRect.height() + viewportMetrics.marginTop + viewportMetrics.marginBottom; float scaleFactor = viewport.height() / pageHeight; minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor); if (viewport.height() > pageHeight) focusY = 0.0f; } maxZoomFactor = Math.max(maxZoomFactor, minZoomFactor); if (zoomFactor < minZoomFactor) { // if one (or both) of the page dimensions is smaller than the viewport, // zoom using the top/left as the focus on that axis. this prevents the // scenario where, if both dimensions are smaller than the viewport, but // by different scale factors, we end up scrolled to the end on one axis // after applying the scale PointF center = new PointF(focusX, focusY); viewportMetrics = viewportMetrics.scaleTo(minZoomFactor, center); } else if (zoomFactor > maxZoomFactor) { PointF center = new PointF(viewport.width() / 2.0f, viewport.height() / 2.0f); viewportMetrics = viewportMetrics.scaleTo(maxZoomFactor, center); } /* Now we pan to the right origin. */ viewportMetrics = viewportMetrics.clampWithMargins(); return viewportMetrics; } private class AxisX extends Axis { AxisX(SubdocumentScrollHelper subscroller) { super(subscroller); } @Override public float getOrigin() { return getMetrics().viewportRectLeft; } @Override protected float getViewportLength() { return getMetrics().getWidth(); } @Override protected float getPageStart() { return getMetrics().pageRectLeft; } @Override protected float getMarginStart() { return mTarget.getMaxMargins().left - getMetrics().marginLeft; } @Override protected float getMarginEnd() { return mTarget.getMaxMargins().right - getMetrics().marginRight; } @Override protected float getPageLength() { return getMetrics().getPageWidthWithMargins(); } @Override protected boolean marginsHidden() { ImmutableViewportMetrics metrics = getMetrics(); RectF maxMargins = mTarget.getMaxMargins(); return (metrics.marginLeft < maxMargins.left || metrics.marginRight < maxMargins.right); } @Override protected void overscrollFling(final float velocity) { if (mOverscroll != null) { mOverscroll.setVelocity(velocity, Overscroll.Axis.X); } } @Override protected void overscrollPan(final float distance) { if (mOverscroll != null) { mOverscroll.setDistance(distance, Overscroll.Axis.X); } } } private class AxisY extends Axis { AxisY(SubdocumentScrollHelper subscroller) { super(subscroller); } @Override public float getOrigin() { return getMetrics().viewportRectTop; } @Override protected float getViewportLength() { return getMetrics().getHeight(); } @Override protected float getPageStart() { return getMetrics().pageRectTop; } @Override protected float getPageLength() { return getMetrics().getPageHeightWithMargins(); } @Override protected float getMarginStart() { return mTarget.getMaxMargins().top - getMetrics().marginTop; } @Override protected float getMarginEnd() { return mTarget.getMaxMargins().bottom - getMetrics().marginBottom; } @Override protected boolean marginsHidden() { ImmutableViewportMetrics metrics = getMetrics(); RectF maxMargins = mTarget.getMaxMargins(); return (metrics.marginTop < maxMargins.top || metrics.marginBottom < maxMargins.bottom); } @Override protected void overscrollFling(final float velocity) { if (mOverscroll != null) { mOverscroll.setVelocity(velocity, Overscroll.Axis.Y); } } @Override protected void overscrollPan(final float distance) { if (mOverscroll != null) { mOverscroll.setDistance(distance, Overscroll.Axis.Y); } } } /* * Zooming */ @Override public boolean onScaleBegin(SimpleScaleGestureDetector detector) { if (mState == PanZoomState.ANIMATED_ZOOM) return false; if (!mTarget.getZoomConstraints().getAllowZoom()) return false; setState(PanZoomState.PINCHING); mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY()); cancelTouch(); GeckoAppShell.sendEventToGecko(GeckoEvent.createNativeGestureEvent(GeckoEvent.ACTION_MAGNIFY_START, mLastZoomFocus, getMetrics().zoomFactor)); return true; } @Override public boolean onScale(SimpleScaleGestureDetector detector) { if (mTarget.isFullScreen()) return false; if (mState != PanZoomState.PINCHING) return false; float prevSpan = detector.getPreviousSpan(); if (FloatUtils.fuzzyEquals(prevSpan, 0.0f)) { // let's eat this one to avoid setting the new zoom to infinity (bug 711453) return true; } synchronized (mTarget.getLock()) { float zoomFactor = getAdjustedZoomFactor(detector.getCurrentSpan() / prevSpan); scrollBy(mLastZoomFocus.x - detector.getFocusX(), mLastZoomFocus.y - detector.getFocusY()); mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY()); ImmutableViewportMetrics target = getMetrics().scaleTo(zoomFactor, mLastZoomFocus); // If overscroll is diabled, prevent zooming outside the normal document pans. if (mX.getOverScrollMode() == View.OVER_SCROLL_NEVER || mY.getOverScrollMode() == View.OVER_SCROLL_NEVER) { target = getValidViewportMetrics(target); } mTarget.setViewportMetrics(target); } GeckoEvent event = GeckoEvent.createNativeGestureEvent(GeckoEvent.ACTION_MAGNIFY, mLastZoomFocus, getMetrics().zoomFactor); GeckoAppShell.sendEventToGecko(event); return true; } private ImmutableViewportMetrics applyZoomDelta(ImmutableViewportMetrics metrics, float zoomDelta) { float oldZoom = metrics.zoomFactor; float newZoom = oldZoom + zoomDelta; float adjustedZoom = getAdjustedZoomFactor(newZoom / oldZoom); // since we don't have a particular focus to zoom to, just use the center PointF center = new PointF(metrics.getWidth() / 2.0f, metrics.getHeight() / 2.0f); metrics = metrics.scaleTo(adjustedZoom, center); return metrics; } private boolean animatedScale(float zoomDelta) { if (mState != PanZoomState.NOTHING && mState != PanZoomState.BOUNCE) { return false; } synchronized (mTarget.getLock()) { ImmutableViewportMetrics metrics = applyZoomDelta(getMetrics(), zoomDelta); bounce(getValidViewportMetrics(metrics), PanZoomState.BOUNCE); } return true; } private float getAdjustedZoomFactor(float zoomRatio) { /* * Apply edge resistance if we're zoomed out smaller than the page size by scaling the zoom * factor toward 1.0. */ float resistance = Math.min(mX.getEdgeResistance(true), mY.getEdgeResistance(true)); if (zoomRatio > 1.0f) zoomRatio = 1.0f + (zoomRatio - 1.0f) * resistance; else zoomRatio = 1.0f - (1.0f - zoomRatio) * resistance; float newZoomFactor = getMetrics().zoomFactor * zoomRatio; float minZoomFactor = 0.0f; float maxZoomFactor = MAX_ZOOM; ZoomConstraints constraints = mTarget.getZoomConstraints(); if (constraints.getMinZoom() > 0) minZoomFactor = constraints.getMinZoom(); if (constraints.getMaxZoom() > 0) maxZoomFactor = constraints.getMaxZoom(); if (newZoomFactor < minZoomFactor) { // apply resistance when zooming past minZoomFactor, // such that it asymptotically reaches minZoomFactor / 2.0 // but never exceeds that final float rate = 0.5f; // controls how quickly we approach the limit float excessZoom = minZoomFactor - newZoomFactor; excessZoom = 1.0f - (float)Math.exp(-excessZoom * rate); newZoomFactor = minZoomFactor * (1.0f - excessZoom / 2.0f); } if (newZoomFactor > maxZoomFactor) { // apply resistance when zooming past maxZoomFactor, // such that it asymptotically reaches maxZoomFactor + 1.0 // but never exceeds that float excessZoom = newZoomFactor - maxZoomFactor; excessZoom = 1.0f - (float)Math.exp(-excessZoom); newZoomFactor = maxZoomFactor + excessZoom; } return newZoomFactor; } @Override public void onScaleEnd(SimpleScaleGestureDetector detector) { if (mState == PanZoomState.ANIMATED_ZOOM) return; // switch back to the touching state startTouch(detector.getFocusX(), detector.getFocusY(), detector.getEventTime()); // Force a viewport synchronisation mTarget.forceRedraw(null); PointF point = new PointF(detector.getFocusX(), detector.getFocusY()); GeckoEvent event = GeckoEvent.createNativeGestureEvent(GeckoEvent.ACTION_MAGNIFY_END, point, getMetrics().zoomFactor); if (event == null) { return; } GeckoAppShell.sendEventToGecko(event); } @Override public boolean getRedrawHint() { switch (mState) { case PINCHING: case ANIMATED_ZOOM: case BOUNCE: // don't redraw during these because the zoom is (or might be, in the case // of BOUNCE) be changing rapidly and gecko will have to redraw the entire // display port area. we trigger a force-redraw upon exiting these states. return false; default: // allow redrawing in other states return true; } } private void sendPointToGecko(String event, MotionEvent motionEvent) { String json; try { PointF point = new PointF(motionEvent.getX(), motionEvent.getY()); point = mTarget.convertViewPointToLayerPoint(point); if (point == null) { return; } json = PointUtils.toJSON(point).toString(); } catch (Exception e) { Log.e(LOGTAG, "Unable to convert point to JSON for " + event, e); return; } GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent(event, json)); } @Override public boolean onDown(MotionEvent motionEvent) { mMediumPress = false; return false; } @Override public void onShowPress(MotionEvent motionEvent) { // If we get this, it will be followed either by a call to // onSingleTapUp (if the user lifts their finger before the // long-press timeout) or a call to onLongPress (if the user // does not). In the former case, we want to make sure it is // treated as a click. (Note that if this is called, we will // not get a call to onDoubleTap). mMediumPress = true; } @Override public void onLongPress(MotionEvent motionEvent) { sendPointToGecko("Gesture:LongPress", motionEvent); } @Override public boolean onSingleTapUp(MotionEvent motionEvent) { // When double-tapping is allowed, we have to wait to see if this is // going to be a double-tap. // However, if mMediumPress is true then we know there will be no // double-tap so we treat this as a click. if (mMediumPress || !mTarget.getZoomConstraints().getAllowDoubleTapZoom()) { sendPointToGecko("Gesture:SingleTap", motionEvent); } // return false because we still want to get the ACTION_UP event that triggers this return false; } @Override public boolean onSingleTapConfirmed(MotionEvent motionEvent) { // When zooming is disabled, we handle this in onSingleTapUp. if (mTarget.getZoomConstraints().getAllowDoubleTapZoom()) { sendPointToGecko("Gesture:SingleTap", motionEvent); } return true; } @Override public boolean onDoubleTap(MotionEvent motionEvent) { if (mTarget.getZoomConstraints().getAllowDoubleTapZoom()) { sendPointToGecko("Gesture:DoubleTap", motionEvent); } return true; } private void cancelTouch() { GeckoEvent e = GeckoEvent.createBroadcastEvent("Gesture:CancelTouch", ""); GeckoAppShell.sendEventToGecko(e); } /** * Zoom to a specified rect IN CSS PIXELS. * * While we usually use device pixels, @zoomToRect must be specified in CSS * pixels. */ private ImmutableViewportMetrics getMetricsToZoomTo(RectF zoomToRect) { final float startZoom = getMetrics().zoomFactor; RectF viewport = getMetrics().getViewport(); // 1. adjust the aspect ratio of zoomToRect to match that of the current viewport, // enlarging as necessary (if it gets too big, it will get shrunk in the next step). // while enlarging make sure we enlarge equally on both sides to keep the target rect // centered. float targetRatio = viewport.width() / viewport.height(); float rectRatio = zoomToRect.width() / zoomToRect.height(); if (FloatUtils.fuzzyEquals(targetRatio, rectRatio)) { // all good, do nothing } else if (targetRatio < rectRatio) { // need to increase zoomToRect height float newHeight = zoomToRect.width() / targetRatio; zoomToRect.top -= (newHeight - zoomToRect.height()) / 2; zoomToRect.bottom = zoomToRect.top + newHeight; } else { // targetRatio > rectRatio) { // need to increase zoomToRect width float newWidth = targetRatio * zoomToRect.height(); zoomToRect.left -= (newWidth - zoomToRect.width()) / 2; zoomToRect.right = zoomToRect.left + newWidth; } float finalZoom = viewport.width() / zoomToRect.width(); ImmutableViewportMetrics finalMetrics = getMetrics(); finalMetrics = finalMetrics.setViewportOrigin( zoomToRect.left * finalMetrics.zoomFactor, zoomToRect.top * finalMetrics.zoomFactor); finalMetrics = finalMetrics.scaleTo(finalZoom, new PointF(0.0f, 0.0f)); // 2. now run getValidViewportMetrics on it, so that the target viewport is // clamped down to prevent overscroll, over-zoom, and other bad conditions. finalMetrics = getValidViewportMetrics(finalMetrics); return finalMetrics; } private boolean animatedZoomTo(RectF zoomToRect) { bounce(getMetricsToZoomTo(zoomToRect), PanZoomState.ANIMATED_ZOOM); return true; } /** This function must be called from the UI thread. */ @Override public void abortPanning() { checkMainThread(); bounce(); } @Override public void setOverScrollMode(int overscrollMode) { mX.setOverScrollMode(overscrollMode); mY.setOverScrollMode(overscrollMode); } @Override public int getOverScrollMode() { return mX.getOverScrollMode(); } @Override public void setOverscrollHandler(final Overscroll handler) { mOverscroll = handler; } }