gecko/mobile/android/base/ui/PanZoomController.java
Chris Lord 8947fea4fd Bug 703141 - Refactor around the idea of a viewport and displayport. r=kats
This patch refactors the code to make some of the value names and ownership
clearer, and to add the idea of a 'viewport' within a 'displayport'. The
displayport is the area of the page which is visible to the underlying buffer
and the viewport is the area of the page which is visible through the
application window.

--HG--
rename : mobile/android/base/ui/ViewportController.java => mobile/android/base/gfx/ViewportMetrics.java
2011-11-23 19:07:29 +00:00

668 lines
24 KiB
Java

/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* ***** BEGIN LICENSE BLOCK *****
* Version: MPL 1.1/GPL 2.0/LGPL 2.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is Mozilla Android code.
*
* The Initial Developer of the Original Code is Mozilla Foundation.
* Portions created by the Initial Developer are Copyright (C) 2009-2010
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Patrick Walton <pcwalton@mozilla.com>
*
* Alternatively, the contents of this file may be used under the terms of
* either the GNU General Public License Version 2 or later (the "GPL"), or
* the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
* in which case the provisions of the GPL or the LGPL are applicable instead
* of those above. If you wish to allow use of your version of this file only
* under the terms of either the GPL or the LGPL, and not to allow others to
* use your version of this file under the terms of the MPL, indicate your
* decision by deleting the provisions above and replace them with the notice
* and other provisions required by the GPL or the LGPL. If you do not delete
* the provisions above, a recipient may use your version of this file under
* the terms of any one of the MPL, the GPL or the LGPL.
*
* ***** END LICENSE BLOCK ***** */
package org.mozilla.gecko.ui;
import org.json.JSONObject;
import org.mozilla.gecko.gfx.IntSize;
import org.mozilla.gecko.gfx.LayerController;
import org.mozilla.gecko.GeckoApp;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.GeckoEvent;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import java.lang.Math;
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 ScaleGestureDetector.OnScaleGestureListener
{
private static final String LOGTAG = "GeckoPanZoomController";
private LayerController mController;
private static final float FRICTION = 0.85f;
// Animation stops if the velocity is below this value.
private static final float STOPPED_THRESHOLD = 4.0f;
// The percentage of the surface which can be overscrolled before it must snap back.
private static final float SNAP_LIMIT = 0.75f;
// The rate of deceleration when the surface has overscrolled.
private static final float OVERSCROLL_DECEL_RATE = 0.04f;
// The duration of animation when bouncing back.
private static final int SNAP_TIME = 240;
// The number of subdivisions we should consider when plotting the ease-out transition. Higher
// values make the animation more accurate, but slower to plot.
private static final int SUBDIVISION_COUNT = 1000;
// 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)
private static final float PAN_THRESHOLD = 4.0f;
// Angle from axis within which we stay axis-locked
private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees
private Timer mFlingTimer;
private Axis mX, mY;
/* The span at the first zoom event (in unzoomed page coordinates). */
private float mInitialZoomSpan;
/* The zoom focus at the first zoom event (in unzoomed page coordinates). */
private PointF mInitialZoomFocus;
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 */
}
private PanZoomState mState;
public PanZoomController(LayerController controller) {
mController = controller;
mX = new Axis(); mY = new Axis();
mState = PanZoomState.NOTHING;
populatePositionAndLength();
}
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
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);
default: return false;
}
}
public void geometryChanged() {
populatePositionAndLength();
}
/*
* Panning/scrolling
*/
private boolean onTouchStart(MotionEvent event) {
// user is taking control of movement, so stop
// any auto-movement we have going
if (mFlingTimer != null) {
mFlingTimer.cancel();
mFlingTimer = null;
}
switch (mState) {
case FLING:
case NOTHING:
mState = PanZoomState.TOUCHING;
mX.firstTouchPos = mX.touchPos = event.getX(0);
mY.firstTouchPos = mY.touchPos = event.getY(0);
return false;
case TOUCHING:
case PANNING:
case PANNING_LOCKED:
case PANNING_HOLD:
case PANNING_HOLD_LOCKED:
case PINCHING:
mState = PanZoomState.PINCHING;
return false;
}
Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchStart");
return false;
}
private boolean onTouchMove(MotionEvent event) {
switch (mState) {
case NOTHING:
case FLING:
// should never happen
Log.e(LOGTAG, "Received impossible touch move while in " + mState);
return false;
case TOUCHING:
if (panDistance(event) < PAN_THRESHOLD)
return false;
// fall through
case PANNING_HOLD_LOCKED:
mState = PanZoomState.PANNING_LOCKED;
// fall through
case PANNING_LOCKED:
track(event);
return true;
case PANNING_HOLD:
mState = 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 NOTHING:
case FLING:
// should never happen
Log.e(LOGTAG, "Received impossible touch end while in " + mState);
return false;
case TOUCHING:
mState = PanZoomState.NOTHING;
// 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
fling();
return false;
case PANNING:
case PANNING_LOCKED:
case PANNING_HOLD:
case PANNING_HOLD_LOCKED:
mState = PanZoomState.FLING;
fling();
return true;
case PINCHING:
int points = event.getPointerCount();
if (points == 1) {
// last touch up
mState = PanZoomState.NOTHING;
} else if (points == 2) {
int pointRemovedIndex = event.getActionIndex();
int pointRemainingIndex = 1 - pointRemovedIndex; // kind of a hack
mState = PanZoomState.TOUCHING;
mX.firstTouchPos = mX.touchPos = event.getX(pointRemainingIndex);
mX.firstTouchPos = mY.touchPos = event.getY(pointRemainingIndex);
} else {
// still pinching, do nothing
}
return true;
}
Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchEnd");
return false;
}
private boolean onTouchCancel(MotionEvent event) {
mState = PanZoomState.NOTHING;
// ensure we snap back if we're overscrolled
fling();
return false;
}
private float panDistance(MotionEvent move) {
float dx = mX.firstTouchPos - move.getX(0);
float dy = mY.firstTouchPos - move.getY(0);
return (float)Math.sqrt(dx * dx + dy * dy);
}
private void track(MotionEvent event) {
float x = event.getX(0);
float y = event.getY(0);
if (mState == PanZoomState.PANNING_LOCKED) {
// check to see if we should break the axis lock
double angle = Math.atan2(y - mY.firstTouchPos, x - mX.firstTouchPos); // range [-pi, pi]
angle = Math.abs(angle); // range [0, pi]
if (angle < AXIS_LOCK_ANGLE || angle > (Math.PI - AXIS_LOCK_ANGLE)) {
// lock to x-axis
y = mY.firstTouchPos;
} else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) {
// lock to y-axis
x = mX.firstTouchPos;
} else {
// break axis lock but log the angle so we can fine-tune this when people complain
mState = PanZoomState.PANNING;
angle = Math.abs(angle - (Math.PI / 2)); // range [0, pi/2]
Log.i(LOGTAG, "Breaking axis lock at " + (angle * 180.0 / Math.PI) + " degrees");
}
}
float zoomFactor = 1.0f;//mController.getZoomFactor();
mX.velocity = (mX.touchPos - x) / zoomFactor;
mY.velocity = (mY.touchPos - y) / zoomFactor;
mX.touchPos = x;
mY.touchPos = y;
if (stopped()) {
if (mState == PanZoomState.PANNING) {
mState = PanZoomState.PANNING_HOLD;
} else if (mState == PanZoomState.PANNING_LOCKED) {
mState = PanZoomState.PANNING_HOLD_LOCKED;
} else {
// should never happen, but handle anyway for robustness
Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track");
mState = PanZoomState.PANNING_HOLD_LOCKED;
}
}
mX.applyEdgeResistance(); mX.displace();
mY.applyEdgeResistance(); mY.displace();
updatePosition();
}
private void fling() {
if (mState != PanZoomState.FLING)
mX.velocity = mY.velocity = 0.0f;
mX.displace(); mY.displace();
updatePosition();
if (mFlingTimer != null)
mFlingTimer.cancel();
boolean stopped = stopped();
mX.startFling(stopped); mY.startFling(stopped);
mFlingTimer = new Timer();
mFlingTimer.scheduleAtFixedRate(new TimerTask() {
public void run() { mController.post(new FlingRunnable()); }
}, 0, 1000L/60L);
}
private boolean stopped() {
float absVelocity = (float)Math.sqrt(mX.velocity * mX.velocity +
mY.velocity * mY.velocity);
return absVelocity < STOPPED_THRESHOLD;
}
private void updatePosition() {
mController.scrollTo(new PointF(mX.viewportPos, mY.viewportPos));
}
// Populates the viewport info and length in the axes.
private void populatePositionAndLength() {
IntSize pageSize = mController.getPageSize();
RectF visibleRect = new RectF(mController.getViewport());
mX.setPageLength(pageSize.width);
mX.viewportPos = visibleRect.left;
mX.setViewportLength(visibleRect.width());
mY.setPageLength(pageSize.height);
mY.viewportPos = visibleRect.top;
mY.setViewportLength(visibleRect.height());
}
// The callback that performs the fling animation.
private class FlingRunnable implements Runnable {
public void run() {
populatePositionAndLength();
mX.advanceFling(); mY.advanceFling();
// If both X and Y axes are overscrolled, we have to wait until both axes have stopped
// to snap back to avoid a jarring effect.
boolean waitingToSnapX = mX.getFlingState() == Axis.FlingStates.WAITING_TO_SNAP;
boolean waitingToSnapY = mY.getFlingState() == Axis.FlingStates.WAITING_TO_SNAP;
if ((mX.getOverscroll() == Axis.Overscroll.PLUS || mX.getOverscroll() == Axis.Overscroll.MINUS) &&
(mY.getOverscroll() == Axis.Overscroll.PLUS || mY.getOverscroll() == Axis.Overscroll.MINUS))
{
if (waitingToSnapX && waitingToSnapY) {
mX.startSnap(); mY.startSnap();
}
} else {
if (waitingToSnapX)
mX.startSnap();
if (waitingToSnapY)
mY.startSnap();
}
mX.displace(); mY.displace();
updatePosition();
if (mX.getFlingState() == Axis.FlingStates.STOPPED &&
mY.getFlingState() == Axis.FlingStates.STOPPED) {
stop();
}
}
private void stop() {
mState = PanZoomState.NOTHING;
if (mFlingTimer != null) {
mFlingTimer.cancel();
mFlingTimer = null;
}
}
}
private float computeElasticity(float excess, float viewportLength) {
return 1.0f - excess / (viewportLength * SNAP_LIMIT);
}
private static boolean floatsApproxEqual(float a, float b) {
// account for floating point rounding errors
return Math.abs(a - b) < 1e-6;
}
// Physics information for one axis (X or Y).
private static class Axis {
public enum FlingStates {
STOPPED,
SCROLLING,
WAITING_TO_SNAP,
SNAPPING,
}
public enum Overscroll {
NONE,
MINUS, // Overscrolled in the negative direction
PLUS, // Overscrolled in the positive direction
BOTH, // Overscrolled in both directions (page is zoomed to smaller than screen)
}
public float firstTouchPos; /* Position of the first touch. */
public float touchPos; /* Position of the last touch. */
public float velocity; /* Velocity in this direction. */
private FlingStates mFlingState; /* The fling state we're in on this axis. */
private EaseOutAnimation mSnapAnim; /* The animation when the page is snapping back. */
/* These three need to be kept in sync with the layer controller. */
public float viewportPos;
private float mViewportLength;
private int mScreenLength;
private int mPageLength;
public FlingStates getFlingState() { return mFlingState; }
public void setViewportLength(float viewportLength) { mViewportLength = viewportLength; }
public void setScreenLength(int screenLength) { mScreenLength = screenLength; }
public void setPageLength(int pageLength) { mPageLength = pageLength; }
private float getViewportEnd() { return viewportPos + mViewportLength; }
public Overscroll getOverscroll() {
boolean minus = (viewportPos < 0.0f);
boolean plus = (getViewportEnd() > mPageLength);
if (minus && plus)
return Overscroll.BOTH;
else if (minus)
return Overscroll.MINUS;
else if (plus)
return Overscroll.PLUS;
else
return Overscroll.NONE;
}
// Returns the amount that the page has been overscrolled. If the page hasn't been
// overscrolled on this axis, returns 0.
private float getExcess() {
switch (getOverscroll()) {
case MINUS: return Math.min(-viewportPos, mPageLength - getViewportEnd());
case PLUS: return Math.min(viewportPos, getViewportEnd() - mPageLength);
default: return 0.0f;
}
}
// Applies resistance along the edges when tracking.
public void applyEdgeResistance() {
float excess = getExcess();
if (excess > 0.0f)
velocity *= SNAP_LIMIT - excess / mViewportLength;
}
public void startFling(boolean stopped) {
if (!stopped) {
mFlingState = FlingStates.SCROLLING;
return;
}
float excess = getExcess();
if (floatsApproxEqual(excess, 0.0f))
mFlingState = FlingStates.STOPPED;
else
mFlingState = FlingStates.WAITING_TO_SNAP;
}
// Advances a fling animation by one step.
public void advanceFling() {
switch (mFlingState) {
case SCROLLING:
scroll();
return;
case WAITING_TO_SNAP:
// We don't do anything until the controller switches us into the snapping state.
return;
case SNAPPING:
snap();
return;
}
}
// Performs one frame of a scroll operation if applicable.
private void scroll() {
// If we aren't overscrolled, just apply friction.
float excess = getExcess();
if (floatsApproxEqual(excess, 0.0f)) {
velocity *= FRICTION;
if (Math.abs(velocity) < 0.1f) {
velocity = 0.0f;
mFlingState = FlingStates.STOPPED;
}
return;
}
// Otherwise, decrease the velocity linearly.
float elasticity = 1.0f - excess / (mViewportLength * SNAP_LIMIT);
if (getOverscroll() == Overscroll.MINUS)
velocity = Math.min((velocity + OVERSCROLL_DECEL_RATE) * elasticity, 0.0f);
else // must be Overscroll.PLUS
velocity = Math.max((velocity - OVERSCROLL_DECEL_RATE) * elasticity, 0.0f);
if (Math.abs(velocity) < 0.3f) {
velocity = 0.0f;
mFlingState = FlingStates.WAITING_TO_SNAP;
}
}
// Starts a snap-into-place operation.
public void startSnap() {
switch (getOverscroll()) {
case MINUS:
mSnapAnim = new EaseOutAnimation(viewportPos, viewportPos + getExcess());
break;
case PLUS:
mSnapAnim = new EaseOutAnimation(viewportPos, viewportPos - getExcess());
break;
default:
// no overscroll to deal with, so we're done
mFlingState = FlingStates.STOPPED;
return;
}
mFlingState = FlingStates.SNAPPING;
}
// Performs one frame of a snap-into-place operation.
private void snap() {
mSnapAnim.advance();
viewportPos = mSnapAnim.getPosition();
if (mSnapAnim.getFinished()) {
mSnapAnim = null;
mFlingState = FlingStates.STOPPED;
}
}
// Performs displacement of the viewport position according to the current velocity.
public void displace() { viewportPos += velocity; }
}
private static class EaseOutAnimation {
private float[] mFrames;
private float mPosition;
private float mOrigin;
private float mDest;
private long mTimestamp;
private boolean mFinished;
public EaseOutAnimation(float position, float dest) {
mPosition = mOrigin = position;
mDest = dest;
mFrames = new float[SNAP_TIME];
mTimestamp = System.currentTimeMillis();
mFinished = false;
plot(position, dest, mFrames);
}
public float getPosition() { return mPosition; }
public boolean getFinished() { return mFinished; }
private void advance() {
int frame = (int)(System.currentTimeMillis() - mTimestamp);
if (frame >= SNAP_TIME) {
mPosition = mDest;
mFinished = true;
return;
}
mPosition = mFrames[frame];
}
private static void plot(float from, float to, float[] frames) {
int nextX = 0;
for (int i = 0; i < SUBDIVISION_COUNT; i++) {
float t = (float)i / (float)SUBDIVISION_COUNT;
float xPos = (3.0f*t*t - 2.0f*t*t*t) * (float)frames.length;
if ((int)xPos < nextX)
continue;
int oldX = nextX;
nextX = (int)xPos;
float yPos = 1.74f*t*t - 0.74f*t*t*t;
float framePos = from + (to - from) * yPos;
while (oldX < nextX)
frames[oldX++] = framePos;
if (nextX >= frames.length)
break;
}
// Pad out any remaining frames.
while (nextX < frames.length) {
frames[nextX] = frames[nextX - 1];
nextX++;
}
}
}
/*
* Zooming
*/
@Override
public boolean onScale(ScaleGestureDetector detector) {
/*
mState = PanZoomState.PINCHING;
float newZoom = detector.getCurrentSpan() / mInitialZoomSpan;
IntSize screenSize = mController.getScreenSize();
float x = mInitialZoomFocus.x - (detector.getFocusX() / newZoom);
float y = mInitialZoomFocus.y - (detector.getFocusY() / newZoom);
float width = screenSize.width / newZoom;
float height = screenSize.height / newZoom;
mController.setVisibleRect(x, y, width, height);
mController.notifyLayerClientOfGeometryChange();
populatePositionAndLength();
*/
return true;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
/*
mState = PanZoomState.PINCHING;
RectF initialZoomRect = mController.getVisibleRect();
float initialZoom = mController.getZoomFactor();
mInitialZoomFocus = new PointF(initialZoomRect.left + (detector.getFocusX() / initialZoom),
initialZoomRect.top + (detector.getFocusY() / initialZoom));
mInitialZoomSpan = detector.getCurrentSpan() / initialZoom;
GeckoApp.mAppContext.hidePluginViews();
*/
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
/*
mState = PanZoomState.PANNING_HOLD_LOCKED;
mX.firstTouchPos = mX.touchPos = detector.getFocusX();
mY.firstTouchPos = mY.touchPos = detector.getFocusY();
GeckoApp.mAppContext.showPluginViews();
*/
}
@Override
public void onLongPress(MotionEvent motionEvent) {
JSONObject ret = new JSONObject();
try {
PointF point = new PointF(motionEvent.getX(), motionEvent.getY());
point = mController.convertViewPointToLayerPoint(point);
if (point == null) {
return;
}
ret.put("x", (int)Math.round(point.x));
ret.put("y", (int)Math.round(point.y));
} catch(Exception ex) {
Log.w(LOGTAG, "Error building return: " + ex);
}
GeckoEvent e = new GeckoEvent("Gesture:LongPress", ret.toString());
GeckoAppShell.sendEventToGecko(e);
}
}