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