diff options
author | Tomaž Vajngerl <tomaz.vajngerl@collabora.com> | 2014-11-30 18:07:19 +0100 |
---|---|---|
committer | Tomaž Vajngerl <tomaz.vajngerl@collabora.com> | 2014-11-30 18:07:19 +0100 |
commit | 365c82688f6cb6ff7fa89010d3da4e7188f04cec (patch) | |
tree | 5c35cf79f43543d19083a423621041d8e10fe471 | |
parent | 17f363f6c70b11237dd58d8be509b8b91e534276 (diff) |
android: extract JavaPanZoomController (PZC becomes an interface)feature/droid_calcimpress3
Change-Id: I87e63008fe7c6db62c18bf461dc4dcda733393ac
4 files changed, 987 insertions, 967 deletions
diff --git a/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/GeckoLayerClient.java b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/GeckoLayerClient.java index 97d0944f1177..d1952491e2d0 100644 --- a/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/GeckoLayerClient.java +++ b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/GeckoLayerClient.java @@ -11,7 +11,6 @@ import android.graphics.PointF; import android.graphics.RectF; import android.util.DisplayMetrics; import android.util.Log; -import android.view.GestureDetector; import org.libreoffice.LOEvent; import org.libreoffice.LOEventFactory; @@ -102,7 +101,7 @@ public class GeckoLayerClient implements PanZoomTarget, LayerView.Listener { mCheckerboardColor = Color.WHITE; mCheckerboardShouldShowChecks = true; - mPanZoomController = new PanZoomController(this); + mPanZoomController = PanZoomController.Factory.create(this); } public void setView(LayerView v) { @@ -465,18 +464,6 @@ public class GeckoLayerClient implements PanZoomTarget, LayerView.Listener { return mContext; } - public GestureDetector.OnGestureListener getGestureListener() { - return mPanZoomController; - } - - public SimpleScaleGestureDetector.SimpleScaleGestureListener getScaleGestureListener() { - return mPanZoomController; - } - - public GestureDetector.OnDoubleTapListener getDoubleTapListener() { - return mPanZoomController; - } - private class AdjustRunnable implements Runnable { public void run() { mPendingViewportAdjust = false; diff --git a/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/JavaPanZoomController.java b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/JavaPanZoomController.java new file mode 100644 index 000000000000..af4713675cd5 --- /dev/null +++ b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/JavaPanZoomController.java @@ -0,0 +1,972 @@ +/* -*- 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 android.graphics.PointF; +import android.graphics.RectF; +import android.util.FloatMath; +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; + +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.mozilla.gecko.ZoomConstraints; +import org.mozilla.gecko.util.FloatUtils; + +import java.util.Timer; +import java.util.TimerTask; + +/* + * Handles the kinetic scrolling and zooming physics for a layer controller. + * + * Many ideas are from Joe Hewitt's Scrollability: + * https://github.com/joehewitt/scrollability/ + */ +public class JavaPanZoomController + extends GestureDetector.SimpleOnGestureListener + implements PanZoomController, SimpleScaleGestureDetector.SimpleScaleGestureListener +{ + private static final String LOGTAG = "GeckoPanZoomController"; + + + // 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 * LOKitShell.getDpi(); + + // Angle from axis within which we stay axis-locked + private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees + + // 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 * LOKitShell.getDpi(); + + 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, /* touch-start followed by move (i.e. panning with axis lock) */ + PANNING, /* panning without axis lock */ + PANNING_HOLD, /* in panning, but not moving. + * similar to TOUCHING but after starting a pan */ + PANNING_HOLD_LOCKED, /* like PANNING_HOLD, but axis lock still in effect */ + 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. */ + } + + private final PanZoomTarget mTarget; + private final SubdocumentScrollHelper mSubscroller; + private final Axis mX; + private final Axis mY; + + private Thread mMainThread; + + /* The timer that handles flings or bounces. */ + private Timer mAnimationTimer; + /* The runnable being scheduled by the animation timer. */ + private AnimationRunnable mAnimationRunnable; + /* 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; + + public JavaPanZoomController(PanZoomTarget target) { + mTarget = target; + mSubscroller = new SubdocumentScrollHelper(); + mX = new AxisX(mSubscroller); + mY = new AxisY(mSubscroller); + + mMainThread = LibreOfficeMainActivity.mAppContext.getMainLooper().getThread(); + checkMainThread(); + + setState(PanZoomState.NOTHING); + } + + public void destroy() { + mSubscroller.destroy(); + } + + private final static float easeOut(float t) { + // ease-out approx. + // -(t-1)^2+1 + t = t-1; + return -t*t+1; + } + + private void setState(PanZoomState state) { + if (state != mState) { + mState = state; + } + } + + private ImmutableViewportMetrics getMetrics() { + return mTarget.getViewportMetrics(); + } + + // for debugging bug 713011; it can be taken out once that is resolved. + private void checkMainThread() { + if (mMainThread != Thread.currentThread()) { + // log with full stack trace + Log.e(LOGTAG, "Uh-oh, we're running on the wrong thread!", new Exception()); + } + } + + public boolean onTouchEvent(MotionEvent event) { + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: return onTouchStart(event); + case MotionEvent.ACTION_MOVE: return onTouchMove(event); + case MotionEvent.ACTION_UP: return onTouchEnd(event); + case MotionEvent.ACTION_CANCEL: return onTouchCancel(event); + case MotionEvent.ACTION_SCROLL: return onScroll(event); + default: return false; + } + } + + /** This function must be called from the UI thread. */ + 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(); + } + 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 function must be called on the UI thread. */ + public void preventedTouchFinished() { + checkMainThread(); + if (mState == PanZoomState.WAITING_LISTENERS) { + // if we enter here, we just finished a block of events whose default actions + // were prevented by touch listeners. Now there are no touch points left, so + // we need to reset our state and re-bounce because we might be in overscroll + bounce(); + } + } + + /** This must be called on the UI thread. */ + 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 onTouchStart(MotionEvent event) { + // user is taking control of movement, so stop + // any auto-movement we have going + stopAnimationTimer(); + + 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(); + // fall through + case FLING: + 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: + case PANNING_HOLD: + case PANNING_HOLD_LOCKED: + case PINCHING: + Log.e(LOGTAG, "Received impossible touch down while in " + mState); + return false; + } + Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchStart"); + return false; + } + + private boolean onTouchMove(MotionEvent event) { + + switch (mState) { + case FLING: + 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: + if (panDistance(event) < PAN_THRESHOLD) { + return false; + } + cancelTouch(); + startPanning(event.getX(0), event.getY(0), event.getEventTime()); + track(event); + return true; + + case PANNING_HOLD_LOCKED: + setState(PanZoomState.PANNING_LOCKED); + // fall through + case PANNING_LOCKED: + 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 onTouchMove"); + return false; + } + + private boolean onTouchEnd(MotionEvent event) { + + switch (mState) { + case FLING: + case BOUNCE: + case WAITING_LISTENERS: + // should never happen + Log.e(LOGTAG, "Received impossible touch end 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 if this happens. + return false; + + 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: + case PANNING_HOLD: + case PANNING_HOLD_LOCKED: + setState(PanZoomState.FLING); + fling(); + return true; + + case PINCHING: + setState(PanZoomState.NOTHING); + return true; + } + Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchEnd"); + return false; + } + + private boolean onTouchCancel(MotionEvent event) { + cancelTouch(); + + if (mState == PanZoomState.WAITING_LISTENERS) { + // we might get a cancel event from the TouchEventHandler while in the + // WAITING_LISTENERS state if the touch listeners prevent-default the + // block of events. at this point being in WAITING_LISTENERS is equivalent + // to being in NOTHING with the exception of possibly being in overscroll. + // so here we don't want to do anything right now; the overscroll will be + // corrected in preventedTouchFinished(). + return false; + } + + // ensure we snap back if we're overscrolled + bounce(); + return false; + } + + private boolean onScroll(MotionEvent event) { + if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) { + float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL); + float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + + scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL); + bounce(); + 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 (!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); + } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) { + mX.setScrollingDisabled(true); + setState(PanZoomState.PANNING_LOCKED); + } else { + 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; + + 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) { + setState(PanZoomState.PANNING_HOLD_LOCKED); + } else { + // should never happen, but handle anyway for robustness + Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track"); + setState(PanZoomState.PANNING_HOLD_LOCKED); + } + } + + mX.startPan(); + mY.startPan(); + updatePosition(); + } + + private void scrollBy(float dx, float dy) { + ImmutableViewportMetrics scrolled = getMetrics().offsetViewportBy(dx, dy); + mTarget.setViewportMetrics(scrolled); + } + + private void fling() { + updatePosition(); + + stopAnimationTimer(); + + boolean stopped = stopped(); + mX.startFling(stopped); + mY.startFling(stopped); + + startAnimationTimer(new FlingRunnable()); + } + + /* Performs a bounce-back animation to the given viewport metrics. */ + private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) { + stopAnimationTimer(); + + 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); + startAnimationTimer(new BounceRunnable(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 startAnimationTimer(final AnimationRunnable runnable) { + if (mAnimationTimer != null) { + Log.e(LOGTAG, "Attempted to start a new fling without canceling the old one!"); + stopAnimationTimer(); + } + + mAnimationTimer = new Timer("Animation Timer"); + mAnimationRunnable = runnable; + mAnimationTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { mTarget.post(runnable); } + }, 0, (int)Axis.MS_PER_FRAME); + } + + /* Stops the fling or bounce animation. */ + private void stopAnimationTimer() { + if (mAnimationTimer != null) { + mAnimationTimer.cancel(); + mAnimationTimer = null; + } + if (mAnimationRunnable != null) { + mAnimationRunnable.terminate(); + mAnimationRunnable = null; + } + } + + private float getVelocity() { + float xvel = mX.getRealVelocity(); + float yvel = mY.getRealVelocity(); + return FloatMath.sqrt(xvel * xvel + yvel * yvel); + } + + 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 (! mSubscroller.scrollBy(displacement)) { + synchronized (mTarget.getLock()) { + scrollBy(displacement.x, displacement.y); + } + } + } + + private abstract class AnimationRunnable implements Runnable { + private boolean mAnimationTerminated; + + /* This should always run on the UI thread */ + public final void run() { + /* + * Since the animation timer queues this runnable on the UI thread, it + * is possible that even when the animation timer is cancelled, there + * are multiple instances of this queued, so we need to have another + * mechanism to abort. This is done by using the mAnimationTerminated flag. + */ + if (mAnimationTerminated) { + return; + } + animateFrame(); + } + + protected abstract void animateFrame(); + + /* This should always run on the UI thread */ + protected final void terminate() { + mAnimationTerminated = true; + } + } + + /* The callback that performs the bounce animation. */ + private class BounceRunnable extends AnimationRunnable { + /* The current frame of the bounce-back animation */ + private int mBounceFrame; + /* + * The viewport metrics that represent the start and end of the bounce-back animation, + * respectively. + */ + private ImmutableViewportMetrics mBounceStartMetrics; + private ImmutableViewportMetrics mBounceEndMetrics; + + BounceRunnable(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) { + mBounceStartMetrics = startMetrics; + mBounceEndMetrics = endMetrics; + } + + 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. */ + if (mBounceFrame < (int)(256f/Axis.MS_PER_FRAME)) { + 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(mBounceFrame * Axis.MS_PER_FRAME / 256f); + ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t); + mTarget.setViewportMetrics(newMetrics); + mBounceFrame++; + } + } + + /* Concludes a bounce animation and snaps the viewport into place. */ + private void finishBounce() { + synchronized (mTarget.getLock()) { + mTarget.setViewportMetrics(mBounceEndMetrics); + mBounceFrame = -1; + } + } + } + + // The callback that performs the fling animation. + private class FlingRunnable extends AnimationRunnable { + 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(); + boolean flingingY = mY.advanceFling(); + + 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(); + + stopAnimationTimer(); + + // Force a viewport synchronisation + mTarget.forceRedraw(); + } + + /* 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 scaleFactor = viewport.width() / pageRect.width(); + minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor); + if (viewport.width() > pageRect.width()) + focusX = 0.0f; + } + /*if (pageRect.height() > 0) { + float scaleFactor = viewport.height() / pageRect.height(); + minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor); + if (viewport.height() > pageRect.height()) + 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.clamp(); + + 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 getPageLength() { return getMetrics().getPageWidth(); } + } + + 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().getPageHeight(); } + } + + /* + * 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(); + + return true; + } + + @Override + public boolean onScale(SimpleScaleGestureDetector detector) { + 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; + } + + float spanRatio = detector.getCurrentSpan() / prevSpan; + + /* + * 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 (spanRatio > 1.0f) + spanRatio = 1.0f + (spanRatio - 1.0f) * resistance; + else + spanRatio = 1.0f - (1.0f - spanRatio) * resistance; + + synchronized (mTarget.getLock()) { + float newZoomFactor = getMetrics().zoomFactor * spanRatio; + 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; + } + + scrollBy(mLastZoomFocus.x - detector.getFocusX(), + mLastZoomFocus.y - detector.getFocusY()); + PointF focus = new PointF(detector.getFocusX(), detector.getFocusY()); + scaleWithFocus(newZoomFactor, focus); + } + + mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY()); + + return true; + } + + @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(); + + } + + /** + * Scales the viewport, keeping the given focus point in the same place before and after the + * scale operation. You must hold the monitor while calling this. + */ + private void scaleWithFocus(float zoomFactor, PointF focus) { + ImmutableViewportMetrics viewportMetrics = getMetrics(); + viewportMetrics = viewportMetrics.scaleTo(zoomFactor, focus); + mTarget.setViewportMetrics(viewportMetrics); + } + + 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; + } + } + + @Override + public void onLongPress(MotionEvent motionEvent) { + } + + @Override + public boolean onSingleTapUp(MotionEvent motionEvent) { + // When zooming is enabled, wait to see if there's a double-tap. + return false; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent motionEvent) { + // When zooming is disabled, we handle this in onSingleTapUp. + return true; + } + + @Override + public boolean onDoubleTap(MotionEvent motionEvent) { + return true; + } + + private void cancelTouch() { + } + + /** + * Zoom to a specified rect IN CSS PIXELS. + * + * While we usually use device pixels, @zoomToRect must be specified in CSS + * pixels. + */ + private boolean animatedZoomTo(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); + + bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM); + return true; + } + + /** This function must be called from the UI thread. */ + public void abortPanning() { + checkMainThread(); + bounce(); + } + + public void setOverScrollMode(int overscrollMode) { + mX.setOverScrollMode(overscrollMode); + mY.setOverScrollMode(overscrollMode); + } + + public int getOverScrollMode() { + return mX.getOverScrollMode(); + } +} diff --git a/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/PanZoomController.java b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/PanZoomController.java index 33471e0a1d52..86036bbea287 100644 --- a/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/PanZoomController.java +++ b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/PanZoomController.java @@ -6,968 +6,29 @@ package org.mozilla.gecko.gfx; import android.graphics.PointF; -import android.graphics.RectF; -import android.util.FloatMath; -import android.util.Log; -import android.view.GestureDetector; -import android.view.MotionEvent; import org.libreoffice.LOKitShell; -import org.libreoffice.LibreOfficeMainActivity; -import org.mozilla.gecko.ZoomConstraints; -import org.mozilla.gecko.gfx.ImmutableViewportMetrics; -import org.mozilla.gecko.util.FloatUtils; - -import java.util.Timer; -import java.util.TimerTask; - -/* - * Handles the kinetic scrolling and zooming physics for a layer controller. - * - * Many ideas are from Joe Hewitt's Scrollability: - * https://github.com/joehewitt/scrollability/ - */ -public class PanZoomController - extends GestureDetector.SimpleOnGestureListener - implements SimpleScaleGestureDetector.SimpleScaleGestureListener -{ - private static final String LOGTAG = "GeckoPanZoomController"; - - - // 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; +public interface PanZoomController { // 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 * LOKitShell.getDpi(); - // Angle from axis within which we stay axis-locked - private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees - - // 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 * LOKitShell.getDpi(); - - 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, /* touch-start followed by move (i.e. panning with axis lock) */ - PANNING, /* panning without axis lock */ - PANNING_HOLD, /* in panning, but not moving. - * similar to TOUCHING but after starting a pan */ - PANNING_HOLD_LOCKED, /* like PANNING_HOLD, but axis lock still in effect */ - 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. */ - } - - private final PanZoomTarget mTarget; - private final SubdocumentScrollHelper mSubscroller; - private final Axis mX; - private final Axis mY; - - private Thread mMainThread; - - /* The timer that handles flings or bounces. */ - private Timer mAnimationTimer; - /* The runnable being scheduled by the animation timer. */ - private AnimationRunnable mAnimationRunnable; - /* 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; - - public PanZoomController(PanZoomTarget target) { - mTarget = target; - mSubscroller = new SubdocumentScrollHelper(); - mX = new AxisX(mSubscroller); - mY = new AxisY(mSubscroller); - - mMainThread = LibreOfficeMainActivity.mAppContext.getMainLooper().getThread(); - checkMainThread(); - - setState(PanZoomState.NOTHING); - } - - public void destroy() { - mSubscroller.destroy(); - } - - private final static float easeOut(float t) { - // ease-out approx. - // -(t-1)^2+1 - t = t-1; - return -t*t+1; - } - - private void setState(PanZoomState state) { - if (state != mState) { - mState = state; - } - } - - private ImmutableViewportMetrics getMetrics() { - return mTarget.getViewportMetrics(); - } - - // for debugging bug 713011; it can be taken out once that is resolved. - private void checkMainThread() { - if (mMainThread != Thread.currentThread()) { - // log with full stack trace - Log.e(LOGTAG, "Uh-oh, we're running on the wrong thread!", new Exception()); - } - } - - public boolean onTouchEvent(MotionEvent event) { - switch (event.getAction() & MotionEvent.ACTION_MASK) { - case MotionEvent.ACTION_DOWN: return onTouchStart(event); - case MotionEvent.ACTION_MOVE: return onTouchMove(event); - case MotionEvent.ACTION_UP: return onTouchEnd(event); - case MotionEvent.ACTION_CANCEL: return onTouchCancel(event); - case MotionEvent.ACTION_SCROLL: return onScroll(event); - default: return false; - } - } - - /** This function must be called from the UI thread. */ - 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(); - } - break; + static class Factory { + static PanZoomController create(PanZoomTarget target) { + return new JavaPanZoomController(target); } } - /** 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); - } - } + public void destroy(); - /** This function must be called on the UI thread. */ - public void preventedTouchFinished() { - checkMainThread(); - if (mState == PanZoomState.WAITING_LISTENERS) { - // if we enter here, we just finished a block of events whose default actions - // were prevented by touch listeners. Now there are no touch points left, so - // we need to reset our state and re-bounce because we might be in overscroll - bounce(); - } - } + public boolean getRedrawHint(); + public PointF getVelocityVector(); - /** This must be called on the UI thread. */ - 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 onTouchStart(MotionEvent event) { - // user is taking control of movement, so stop - // any auto-movement we have going - stopAnimationTimer(); - - 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(); - // fall through - case FLING: - 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: - case PANNING_HOLD: - case PANNING_HOLD_LOCKED: - case PINCHING: - Log.e(LOGTAG, "Received impossible touch down while in " + mState); - return false; - } - Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchStart"); - return false; - } - - private boolean onTouchMove(MotionEvent event) { - - switch (mState) { - case FLING: - 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: - if (panDistance(event) < PAN_THRESHOLD) { - return false; - } - cancelTouch(); - startPanning(event.getX(0), event.getY(0), event.getEventTime()); - track(event); - return true; - - case PANNING_HOLD_LOCKED: - setState(PanZoomState.PANNING_LOCKED); - // fall through - case PANNING_LOCKED: - 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 onTouchMove"); - return false; - } - - private boolean onTouchEnd(MotionEvent event) { - - switch (mState) { - case FLING: - case BOUNCE: - case WAITING_LISTENERS: - // should never happen - Log.e(LOGTAG, "Received impossible touch end 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 if this happens. - return false; - - 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: - case PANNING_HOLD: - case PANNING_HOLD_LOCKED: - setState(PanZoomState.FLING); - fling(); - return true; - - case PINCHING: - setState(PanZoomState.NOTHING); - return true; - } - Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchEnd"); - return false; - } - - private boolean onTouchCancel(MotionEvent event) { - cancelTouch(); - - if (mState == PanZoomState.WAITING_LISTENERS) { - // we might get a cancel event from the TouchEventHandler while in the - // WAITING_LISTENERS state if the touch listeners prevent-default the - // block of events. at this point being in WAITING_LISTENERS is equivalent - // to being in NOTHING with the exception of possibly being in overscroll. - // so here we don't want to do anything right now; the overscroll will be - // corrected in preventedTouchFinished(). - return false; - } - - // ensure we snap back if we're overscrolled - bounce(); - return false; - } - - private boolean onScroll(MotionEvent event) { - if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) { - float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL); - float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL); - - scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL); - bounce(); - 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 (!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); - } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) { - mX.setScrollingDisabled(true); - setState(PanZoomState.PANNING_LOCKED); - } else { - 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; - - 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) { - setState(PanZoomState.PANNING_HOLD_LOCKED); - } else { - // should never happen, but handle anyway for robustness - Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track"); - setState(PanZoomState.PANNING_HOLD_LOCKED); - } - } - - mX.startPan(); - mY.startPan(); - updatePosition(); - } - - private void scrollBy(float dx, float dy) { - ImmutableViewportMetrics scrolled = getMetrics().offsetViewportBy(dx, dy); - mTarget.setViewportMetrics(scrolled); - } - - private void fling() { - updatePosition(); - - stopAnimationTimer(); - - boolean stopped = stopped(); - mX.startFling(stopped); - mY.startFling(stopped); - - startAnimationTimer(new FlingRunnable()); - } - - /* Performs a bounce-back animation to the given viewport metrics. */ - private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) { - stopAnimationTimer(); - - 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); - startAnimationTimer(new BounceRunnable(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 startAnimationTimer(final AnimationRunnable runnable) { - if (mAnimationTimer != null) { - Log.e(LOGTAG, "Attempted to start a new fling without canceling the old one!"); - stopAnimationTimer(); - } - - mAnimationTimer = new Timer("Animation Timer"); - mAnimationRunnable = runnable; - mAnimationTimer.scheduleAtFixedRate(new TimerTask() { - @Override - public void run() { mTarget.post(runnable); } - }, 0, (int)Axis.MS_PER_FRAME); - } - - /* Stops the fling or bounce animation. */ - private void stopAnimationTimer() { - if (mAnimationTimer != null) { - mAnimationTimer.cancel(); - mAnimationTimer = null; - } - if (mAnimationRunnable != null) { - mAnimationRunnable.terminate(); - mAnimationRunnable = null; - } - } - - private float getVelocity() { - float xvel = mX.getRealVelocity(); - float yvel = mY.getRealVelocity(); - return FloatMath.sqrt(xvel * xvel + yvel * yvel); - } - - public PointF getVelocityVector() { - return new PointF(mX.getRealVelocity(), mY.getRealVelocity()); - } + public void pageRectUpdated(); + public void abortPanning(); + public void abortAnimation(); - 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 (! mSubscroller.scrollBy(displacement)) { - synchronized (mTarget.getLock()) { - scrollBy(displacement.x, displacement.y); - } - } - } - - private abstract class AnimationRunnable implements Runnable { - private boolean mAnimationTerminated; - - /* This should always run on the UI thread */ - public final void run() { - /* - * Since the animation timer queues this runnable on the UI thread, it - * is possible that even when the animation timer is cancelled, there - * are multiple instances of this queued, so we need to have another - * mechanism to abort. This is done by using the mAnimationTerminated flag. - */ - if (mAnimationTerminated) { - return; - } - animateFrame(); - } - - protected abstract void animateFrame(); - - /* This should always run on the UI thread */ - protected final void terminate() { - mAnimationTerminated = true; - } - } - - /* The callback that performs the bounce animation. */ - private class BounceRunnable extends AnimationRunnable { - /* The current frame of the bounce-back animation */ - private int mBounceFrame; - /* - * The viewport metrics that represent the start and end of the bounce-back animation, - * respectively. - */ - private ImmutableViewportMetrics mBounceStartMetrics; - private ImmutableViewportMetrics mBounceEndMetrics; - - BounceRunnable(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) { - mBounceStartMetrics = startMetrics; - mBounceEndMetrics = endMetrics; - } - - 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. */ - if (mBounceFrame < (int)(256f/Axis.MS_PER_FRAME)) { - 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(mBounceFrame * Axis.MS_PER_FRAME / 256f); - ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t); - mTarget.setViewportMetrics(newMetrics); - mBounceFrame++; - } - } - - /* Concludes a bounce animation and snaps the viewport into place. */ - private void finishBounce() { - synchronized (mTarget.getLock()) { - mTarget.setViewportMetrics(mBounceEndMetrics); - mBounceFrame = -1; - } - } - } - - // The callback that performs the fling animation. - private class FlingRunnable extends AnimationRunnable { - 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(); - boolean flingingY = mY.advanceFling(); - - 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(); - - stopAnimationTimer(); - - // Force a viewport synchronisation - mTarget.forceRedraw(); - } - - /* 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 scaleFactor = viewport.width() / pageRect.width(); - minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor); - if (viewport.width() > pageRect.width()) - focusX = 0.0f; - } - /*if (pageRect.height() > 0) { - float scaleFactor = viewport.height() / pageRect.height(); - minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor); - if (viewport.height() > pageRect.height()) - 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.clamp(); - - 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 getPageLength() { return getMetrics().getPageWidth(); } - } - - 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().getPageHeight(); } - } - - /* - * 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(); - - return true; - } - - @Override - public boolean onScale(SimpleScaleGestureDetector detector) { - 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; - } - - float spanRatio = detector.getCurrentSpan() / prevSpan; - - /* - * 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 (spanRatio > 1.0f) - spanRatio = 1.0f + (spanRatio - 1.0f) * resistance; - else - spanRatio = 1.0f - (1.0f - spanRatio) * resistance; - - synchronized (mTarget.getLock()) { - float newZoomFactor = getMetrics().zoomFactor * spanRatio; - 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; - } - - scrollBy(mLastZoomFocus.x - detector.getFocusX(), - mLastZoomFocus.y - detector.getFocusY()); - PointF focus = new PointF(detector.getFocusX(), detector.getFocusY()); - scaleWithFocus(newZoomFactor, focus); - } - - mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY()); - - return true; - } - - @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(); - - } - - /** - * Scales the viewport, keeping the given focus point in the same place before and after the - * scale operation. You must hold the monitor while calling this. - */ - private void scaleWithFocus(float zoomFactor, PointF focus) { - ImmutableViewportMetrics viewportMetrics = getMetrics(); - viewportMetrics = viewportMetrics.scaleTo(zoomFactor, focus); - mTarget.setViewportMetrics(viewportMetrics); - } - - 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; - } - } - - @Override - public void onLongPress(MotionEvent motionEvent) { - } - - @Override - public boolean onSingleTapUp(MotionEvent motionEvent) { - // When zooming is enabled, wait to see if there's a double-tap. - return false; - } - - @Override - public boolean onSingleTapConfirmed(MotionEvent motionEvent) { - // When zooming is disabled, we handle this in onSingleTapUp. - return true; - } - - @Override - public boolean onDoubleTap(MotionEvent motionEvent) { - return true; - } - - private void cancelTouch() { - } - - /** - * Zoom to a specified rect IN CSS PIXELS. - * - * While we usually use device pixels, @zoomToRect must be specified in CSS - * pixels. - */ - private boolean animatedZoomTo(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); - - bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM); - return true; - } - - /** This function must be called from the UI thread. */ - public void abortPanning() { - checkMainThread(); - bounce(); - } - - public void setOverScrollMode(int overscrollMode) { - mX.setOverScrollMode(overscrollMode); - mY.setOverScrollMode(overscrollMode); - } - - public int getOverScrollMode() { - return mX.getOverScrollMode(); - } + public void setOverScrollMode(int overscrollMode); + public int getOverScrollMode(); } diff --git a/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/TouchEventHandler.java b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/TouchEventHandler.java index 5e88cc20627c..cba380236465 100644 --- a/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/TouchEventHandler.java +++ b/android/experimental/LOAndroid3/src/java/org/mozilla/gecko/gfx/TouchEventHandler.java @@ -57,7 +57,7 @@ public final class TouchEventHandler { private final LayerView mView; private final GestureDetector mGestureDetector; private final SimpleScaleGestureDetector mScaleGestureDetector; - private final PanZoomController mPanZoomController; + private final JavaPanZoomController mPanZoomController; // the queue of events that we are holding on to while waiting for a preventDefault // notification @@ -126,7 +126,7 @@ public final class TouchEventHandler { mView = view; mEventQueue = new LinkedList<MotionEvent>(); - mPanZoomController = layerClient.getPanZoomController(); + mPanZoomController = (JavaPanZoomController)layerClient.getPanZoomController(); mGestureDetector = new GestureDetector(context, mPanZoomController); mScaleGestureDetector = new SimpleScaleGestureDetector(mPanZoomController); mListenerTimeoutProcessor = new ListenerTimeoutProcessor(); |