Avoid potential negative array index access to cached text.
[LibreOffice.git] / android / source / src / java / org / mozilla / gecko / gfx / JavaPanZoomController.java
blobb20d602a21cbdef8cf24d704b9e2c549e53d0b87
1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 package org.mozilla.gecko.gfx;
8 import android.graphics.PointF;
9 import android.graphics.RectF;
10 import android.os.Build;
11 import android.util.Log;
12 import android.view.GestureDetector;
13 import android.view.InputDevice;
14 import android.view.MotionEvent;
15 import android.view.View;
17 import org.libreoffice.LOKitShell;
18 import org.libreoffice.LibreOfficeMainActivity;
19 import org.mozilla.gecko.ZoomConstraints;
20 import org.mozilla.gecko.util.FloatUtils;
22 import java.util.Timer;
23 import java.util.TimerTask;
24 import java.lang.StrictMath;
27 * Handles the kinetic scrolling and zooming physics for a layer controller.
29 * Many ideas are from Joe Hewitt's Scrollability:
30 * https://github.com/joehewitt/scrollability/
32 class JavaPanZoomController
33 extends GestureDetector.SimpleOnGestureListener
34 implements PanZoomController, SimpleScaleGestureDetector.SimpleScaleGestureListener
36 private static final String LOGTAG = "GeckoPanZoomController";
38 // Animation stops if the velocity is below this value when overscrolled or panning.
39 private static final float STOPPED_THRESHOLD = 4.0f;
41 // Animation stops is the velocity is below this threshold when flinging.
42 private static final float FLING_STOPPED_THRESHOLD = 0.1f;
44 // The distance the user has to pan before we recognize it as such (e.g. to avoid 1-pixel pans
45 // between the touch-down and touch-up of a click). In units of density-independent pixels.
46 private final float PAN_THRESHOLD;
48 // Angle from axis within which we stay axis-locked
49 private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees
51 // The maximum amount we allow you to zoom into a page
52 private static final float MAX_ZOOM = 4.0f;
54 // The threshold zoom factor of whether a double tap triggers zoom-in or zoom-out
55 private static final float DOUBLE_TAP_THRESHOLD = 1.0f;
57 // The maximum amount we would like to scroll with the mouse
58 private final float MAX_SCROLL;
60 private enum PanZoomState {
61 NOTHING, /* no touch-start events received */
62 FLING, /* all touches removed, but we're still scrolling page */
63 TOUCHING, /* one touch-start event received */
64 PANNING_LOCKED, /* touch-start followed by move (i.e. panning with axis lock) */
65 PANNING, /* panning without axis lock */
66 PANNING_HOLD, /* in panning, but not moving.
67 * similar to TOUCHING but after starting a pan */
68 PANNING_HOLD_LOCKED, /* like PANNING_HOLD, but axis lock still in effect */
69 PINCHING, /* nth touch-start, where n > 1. this mode allows pan and zoom */
70 ANIMATED_ZOOM, /* animated zoom to a new rect */
71 BOUNCE, /* in a bounce animation */
73 WAITING_LISTENERS, /* a state halfway between NOTHING and TOUCHING - the user has
74 put a finger down, but we don't yet know if a touch listener has
75 prevented the default actions yet. we still need to abort animations. */
78 private final PanZoomTarget mTarget;
79 private final SubdocumentScrollHelper mSubscroller;
80 private final Axis mX;
81 private final Axis mY;
82 private final TouchEventHandler mTouchEventHandler;
83 private Thread mMainThread;
84 private LibreOfficeMainActivity mContext;
86 /* The timer that handles flings or bounces. */
87 private Timer mAnimationTimer;
88 /* The runnable being scheduled by the animation timer. */
89 private AnimationRunnable mAnimationRunnable;
90 /* The zoom focus at the first zoom event (in page coordinates). */
91 private PointF mLastZoomFocus;
92 /* The time the last motion event took place. */
93 private long mLastEventTime;
94 /* Current state the pan/zoom UI is in. */
95 private PanZoomState mState;
96 /* Whether or not to wait for a double-tap before dispatching a single-tap */
97 private boolean mWaitForDoubleTap;
99 JavaPanZoomController(LibreOfficeMainActivity context, PanZoomTarget target, View view) {
100 mContext = context;
101 PAN_THRESHOLD = 1/16f * LOKitShell.getDpi(view.getContext());
102 MAX_SCROLL = 0.075f * LOKitShell.getDpi(view.getContext());
103 mTarget = target;
104 mSubscroller = new SubdocumentScrollHelper();
105 mX = new AxisX(mSubscroller);
106 mY = new AxisY(mSubscroller);
107 mTouchEventHandler = new TouchEventHandler(view.getContext(), view, this);
109 mMainThread = mContext.getMainLooper().getThread();
110 checkMainThread();
112 setState(PanZoomState.NOTHING);
115 public void destroy() {
116 mSubscroller.destroy();
117 mTouchEventHandler.destroy();
120 private static float easeOut(float t) {
121 // ease-out approx.
122 // -(t-1)^2+1
123 t = t-1;
124 return -t*t+1;
127 private void setState(PanZoomState state) {
128 if (state != mState) {
129 mState = state;
133 private ImmutableViewportMetrics getMetrics() {
134 return mTarget.getViewportMetrics();
137 // for debugging bug 713011; it can be taken out once that is resolved.
138 private void checkMainThread() {
139 if (mMainThread != Thread.currentThread()) {
140 // log with full stack trace
141 Log.e(LOGTAG, "Uh-oh, we're running on the wrong thread!", new Exception());
145 /** This function MUST be called on the UI thread */
146 public boolean onMotionEvent(MotionEvent event) {
147 if ((event.getSource() & InputDevice.SOURCE_CLASS_MASK) == InputDevice.SOURCE_CLASS_POINTER
148 && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_SCROLL) {
149 return handlePointerScroll(event);
151 return false;
154 /** This function MUST be called on the UI thread */
155 public boolean onTouchEvent(MotionEvent event) {
156 return mTouchEventHandler.handleEvent(event);
159 boolean handleEvent(MotionEvent event) {
160 switch (event.getAction() & MotionEvent.ACTION_MASK) {
161 case MotionEvent.ACTION_DOWN: return handleTouchStart(event);
162 case MotionEvent.ACTION_MOVE: return handleTouchMove(event);
163 case MotionEvent.ACTION_UP: return handleTouchEnd(event);
164 case MotionEvent.ACTION_CANCEL: return handleTouchCancel(event);
166 return false;
169 /** This function MUST be called on the UI thread */
170 public void notifyDefaultActionPrevented(boolean prevented) {
171 mTouchEventHandler.handleEventListenerAction(!prevented);
174 /** This function must be called from the UI thread. */
175 public void abortAnimation() {
176 checkMainThread();
177 // this happens when gecko changes the viewport on us or if the device is rotated.
178 // if that's the case, abort any animation in progress and re-zoom so that the page
179 // snaps to edges. for other cases (where the user's finger(s) are down) don't do
180 // anything special.
181 switch (mState) {
182 case FLING:
183 mX.stopFling();
184 mY.stopFling();
185 // fall through
186 case BOUNCE:
187 case ANIMATED_ZOOM:
188 // the zoom that's in progress likely makes no sense any more (such as if
189 // the screen orientation changed) so abort it
190 setState(PanZoomState.NOTHING);
191 // fall through
192 case NOTHING:
193 // Don't do animations here; they're distracting and can cause flashes on page
194 // transitions.
195 synchronized (mTarget.getLock()) {
196 mTarget.setViewportMetrics(getValidViewportMetrics());
197 mTarget.forceRedraw();
199 break;
203 /** This function must be called on the UI thread. */
204 void startingNewEventBlock(MotionEvent event, boolean waitingForTouchListeners) {
205 checkMainThread();
206 mSubscroller.cancel();
207 if (waitingForTouchListeners && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) {
208 // this is the first touch point going down, so we enter the pending state
209 // setting the state will kill any animations in progress, possibly leaving
210 // the page in overscroll
211 setState(PanZoomState.WAITING_LISTENERS);
215 /** This function must be called on the UI thread. */
216 void preventedTouchFinished() {
217 checkMainThread();
218 if (mState == PanZoomState.WAITING_LISTENERS) {
219 // if we enter here, we just finished a block of events whose default actions
220 // were prevented by touch listeners. Now there are no touch points left, so
221 // we need to reset our state and re-bounce because we might be in overscroll
222 bounce();
226 /** This must be called on the UI thread. */
227 public void pageRectUpdated() {
228 if (mState == PanZoomState.NOTHING) {
229 synchronized (mTarget.getLock()) {
230 ImmutableViewportMetrics validated = getValidViewportMetrics();
231 if (!getMetrics().fuzzyEquals(validated)) {
232 // page size changed such that we are now in overscroll. snap to
233 // the nearest valid viewport
234 mTarget.setViewportMetrics(validated);
241 * Panning/scrolling
244 private boolean handleTouchStart(MotionEvent event) {
245 // user is taking control of movement, so stop
246 // any auto-movement we have going
247 stopAnimationTimer();
249 switch (mState) {
250 case ANIMATED_ZOOM:
251 // We just interrupted a double-tap animation, so force a redraw in
252 // case this touchstart is just a tap that doesn't end up triggering
253 // a redraw
254 mTarget.forceRedraw();
255 // fall through
256 case FLING:
257 case BOUNCE:
258 case NOTHING:
259 case WAITING_LISTENERS:
260 startTouch(event.getX(0), event.getY(0), event.getEventTime());
261 return false;
262 case TOUCHING:
263 case PANNING:
264 case PANNING_LOCKED:
265 case PANNING_HOLD:
266 case PANNING_HOLD_LOCKED:
267 case PINCHING:
268 Log.e(LOGTAG, "Received impossible touch down while in " + mState);
269 return false;
271 Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchStart");
272 return false;
275 private boolean handleTouchMove(MotionEvent event) {
276 if (mState == PanZoomState.PANNING_LOCKED || mState == PanZoomState.PANNING) {
277 if (getVelocity() > 18.0f) {
278 mContext.hideSoftKeyboard();
282 switch (mState) {
283 case FLING:
284 case BOUNCE:
285 case WAITING_LISTENERS:
286 // should never happen
287 Log.e(LOGTAG, "Received impossible touch move while in " + mState);
288 // fall through
289 case ANIMATED_ZOOM:
290 case NOTHING:
291 // may happen if user double-taps and drags without lifting after the
292 // second tap. ignore the move if this happens.
293 return false;
295 case TOUCHING:
296 // Don't allow panning if there is an element in full-screen mode. See bug 775511.
297 if (mTarget.isFullScreen() || panDistance(event) < PAN_THRESHOLD) {
298 return false;
300 cancelTouch();
301 startPanning(event.getX(0), event.getY(0), event.getEventTime());
302 track(event);
303 return true;
305 case PANNING_HOLD_LOCKED:
306 setState(PanZoomState.PANNING_LOCKED);
307 // fall through
308 case PANNING_LOCKED:
309 track(event);
310 return true;
312 case PANNING_HOLD:
313 setState(PanZoomState.PANNING);
314 // fall through
315 case PANNING:
316 track(event);
317 return true;
319 case PINCHING:
320 // scale gesture listener will handle this
321 return false;
323 Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchMove");
324 return false;
327 private boolean handleTouchEnd(MotionEvent event) {
329 switch (mState) {
330 case FLING:
331 case BOUNCE:
332 case WAITING_LISTENERS:
333 // should never happen
334 Log.e(LOGTAG, "Received impossible touch end while in " + mState);
335 // fall through
336 case ANIMATED_ZOOM:
337 case NOTHING:
338 // may happen if user double-taps and drags without lifting after the
339 // second tap. ignore if this happens.
340 return false;
342 case TOUCHING:
343 // the switch into TOUCHING might have happened while the page was
344 // snapping back after overscroll. we need to finish the snap if that
345 // was the case
346 bounce();
347 return false;
349 case PANNING:
350 case PANNING_LOCKED:
351 case PANNING_HOLD:
352 case PANNING_HOLD_LOCKED:
353 setState(PanZoomState.FLING);
354 fling();
355 return true;
357 case PINCHING:
358 setState(PanZoomState.NOTHING);
359 return true;
361 Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchEnd");
362 return false;
365 private boolean handleTouchCancel(MotionEvent event) {
366 cancelTouch();
368 if (mState == PanZoomState.WAITING_LISTENERS) {
369 // we might get a cancel event from the TouchEventHandler while in the
370 // WAITING_LISTENERS state if the touch listeners prevent-default the
371 // block of events. at this point being in WAITING_LISTENERS is equivalent
372 // to being in NOTHING with the exception of possibly being in overscroll.
373 // so here we don't want to do anything right now; the overscroll will be
374 // corrected in preventedTouchFinished().
375 return false;
378 // ensure we snap back if we're overscrolled
379 bounce();
380 return false;
383 private boolean handlePointerScroll(MotionEvent event) {
384 if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) {
385 float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
386 float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
388 scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL);
389 bounce();
390 return true;
392 return false;
395 private void startTouch(float x, float y, long time) {
396 mX.startTouch(x);
397 mY.startTouch(y);
398 setState(PanZoomState.TOUCHING);
399 mLastEventTime = time;
402 private void startPanning(float x, float y, long time) {
403 float dx = mX.panDistance(x);
404 float dy = mY.panDistance(y);
405 double angle = Math.atan2(dy, dx); // range [-pi, pi]
406 angle = Math.abs(angle); // range [0, pi]
408 // When the touch move breaks through the pan threshold, reposition the touch down origin
409 // so the page won't jump when we start panning.
410 mX.startTouch(x);
411 mY.startTouch(y);
412 mLastEventTime = time;
414 if (!mX.scrollable() || !mY.scrollable()) {
415 setState(PanZoomState.PANNING);
416 } else if (angle < AXIS_LOCK_ANGLE || angle > (Math.PI - AXIS_LOCK_ANGLE)) {
417 mY.setScrollingDisabled(true);
418 setState(PanZoomState.PANNING_LOCKED);
419 } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) {
420 mX.setScrollingDisabled(true);
421 setState(PanZoomState.PANNING_LOCKED);
422 } else {
423 setState(PanZoomState.PANNING);
427 private float panDistance(MotionEvent move) {
428 float dx = mX.panDistance(move.getX(0));
429 float dy = mY.panDistance(move.getY(0));
430 return (float) Math.hypot(dx , dy);
433 private void track(float x, float y, long time) {
434 float timeDelta = (float)(time - mLastEventTime);
435 if (FloatUtils.fuzzyEquals(timeDelta, 0)) {
436 // probably a duplicate event, ignore it. using a zero timeDelta will mess
437 // up our velocity
438 return;
440 mLastEventTime = time;
442 mX.updateWithTouchAt(x, timeDelta);
443 mY.updateWithTouchAt(y, timeDelta);
446 private void track(MotionEvent event) {
447 mX.saveTouchPos();
448 mY.saveTouchPos();
450 for (int i = 0; i < event.getHistorySize(); i++) {
451 track(event.getHistoricalX(0, i),
452 event.getHistoricalY(0, i),
453 event.getHistoricalEventTime(i));
455 track(event.getX(0), event.getY(0), event.getEventTime());
457 if (stopped()) {
458 if (mState == PanZoomState.PANNING) {
459 setState(PanZoomState.PANNING_HOLD);
460 } else if (mState == PanZoomState.PANNING_LOCKED) {
461 setState(PanZoomState.PANNING_HOLD_LOCKED);
462 } else {
463 // should never happen, but handle anyway for robustness
464 Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track");
465 setState(PanZoomState.PANNING_HOLD_LOCKED);
469 mX.startPan();
470 mY.startPan();
471 updatePosition();
474 private void scrollBy(float dx, float dy) {
475 ImmutableViewportMetrics scrolled = getMetrics().offsetViewportBy(dx, dy);
476 mTarget.setViewportMetrics(scrolled);
479 private void fling() {
480 updatePosition();
482 stopAnimationTimer();
484 boolean stopped = stopped();
485 mX.startFling(stopped);
486 mY.startFling(stopped);
488 startAnimationTimer(new FlingRunnable());
491 /* Performs a bounce-back animation to the given viewport metrics. */
492 private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) {
493 stopAnimationTimer();
495 ImmutableViewportMetrics bounceStartMetrics = getMetrics();
496 if (bounceStartMetrics.fuzzyEquals(metrics)) {
497 setState(PanZoomState.NOTHING);
498 finishAnimation();
499 return;
502 setState(state);
504 // At this point we have already set mState to BOUNCE or ANIMATED_ZOOM, so
505 // getRedrawHint() is returning false. This means we can safely call
506 // setAnimationTarget to set the new final display port and not have it get
507 // clobbered by display ports from intermediate animation frames.
508 mTarget.setAnimationTarget(metrics);
509 startAnimationTimer(new BounceRunnable(bounceStartMetrics, metrics));
512 /* Performs a bounce-back animation to the nearest valid viewport metrics. */
513 private void bounce() {
514 bounce(getValidViewportMetrics(), PanZoomState.BOUNCE);
517 /* Starts the fling or bounce animation. */
518 private void startAnimationTimer(final AnimationRunnable runnable) {
519 if (mAnimationTimer != null) {
520 Log.e(LOGTAG, "Attempted to start a new fling without canceling the old one!");
521 stopAnimationTimer();
524 mAnimationTimer = new Timer("Animation Timer");
525 mAnimationRunnable = runnable;
526 mAnimationTimer.scheduleAtFixedRate(new TimerTask() {
527 @Override
528 public void run() { mTarget.post(runnable); }
529 }, 0, (int)Axis.MS_PER_FRAME);
532 /* Stops the fling or bounce animation. */
533 private void stopAnimationTimer() {
534 if (mAnimationTimer != null) {
535 mAnimationTimer.cancel();
536 mAnimationTimer = null;
538 if (mAnimationRunnable != null) {
539 mAnimationRunnable.terminate();
540 mAnimationRunnable = null;
544 private float getVelocity() {
545 float xvel = mX.getRealVelocity();
546 float yvel = mY.getRealVelocity();
547 return (float) StrictMath.hypot(xvel, yvel);
550 public PointF getVelocityVector() {
551 return new PointF(mX.getRealVelocity(), mY.getRealVelocity());
554 private boolean stopped() {
555 return getVelocity() < STOPPED_THRESHOLD;
558 private PointF resetDisplacement() {
559 return new PointF(mX.resetDisplacement(), mY.resetDisplacement());
562 private void updatePosition() {
563 mX.displace();
564 mY.displace();
565 PointF displacement = resetDisplacement();
566 if (FloatUtils.fuzzyEquals(displacement.x, 0.0f) && FloatUtils.fuzzyEquals(displacement.y, 0.0f)) {
567 return;
569 if (! mSubscroller.scrollBy(displacement)) {
570 synchronized (mTarget.getLock()) {
571 scrollBy(displacement.x, displacement.y);
576 private abstract class AnimationRunnable implements Runnable {
577 private boolean mAnimationTerminated;
579 /* This should always run on the UI thread */
580 public final void run() {
582 * Since the animation timer queues this runnable on the UI thread, it
583 * is possible that even when the animation timer is cancelled, there
584 * are multiple instances of this queued, so we need to have another
585 * mechanism to abort. This is done by using the mAnimationTerminated flag.
587 if (mAnimationTerminated) {
588 return;
590 animateFrame();
593 protected abstract void animateFrame();
595 /* This should always run on the UI thread */
596 final void terminate() {
597 mAnimationTerminated = true;
601 /* The callback that performs the bounce animation. */
602 private class BounceRunnable extends AnimationRunnable {
603 /* The current frame of the bounce-back animation */
604 private int mBounceFrame;
606 * The viewport metrics that represent the start and end of the bounce-back animation,
607 * respectively.
609 private ImmutableViewportMetrics mBounceStartMetrics;
610 private ImmutableViewportMetrics mBounceEndMetrics;
612 BounceRunnable(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) {
613 mBounceStartMetrics = startMetrics;
614 mBounceEndMetrics = endMetrics;
617 protected void animateFrame() {
619 * The pan/zoom controller might have signaled to us that it wants to abort the
620 * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
621 * out.
623 if (!(mState == PanZoomState.BOUNCE || mState == PanZoomState.ANIMATED_ZOOM)) {
624 finishAnimation();
625 return;
628 /* Perform the next frame of the bounce-back animation. */
629 if (mBounceFrame < (int)(256f/Axis.MS_PER_FRAME)) {
630 advanceBounce();
631 return;
634 /* Finally, if there's nothing else to do, complete the animation and go to sleep. */
635 finishBounce();
636 finishAnimation();
637 setState(PanZoomState.NOTHING);
640 /* Performs one frame of a bounce animation. */
641 private void advanceBounce() {
642 synchronized (mTarget.getLock()) {
643 float t = easeOut(mBounceFrame * Axis.MS_PER_FRAME / 256f);
644 ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t);
645 mTarget.setViewportMetrics(newMetrics);
646 mBounceFrame++;
650 /* Concludes a bounce animation and snaps the viewport into place. */
651 private void finishBounce() {
652 synchronized (mTarget.getLock()) {
653 mTarget.setViewportMetrics(mBounceEndMetrics);
654 mBounceFrame = -1;
659 // The callback that performs the fling animation.
660 private class FlingRunnable extends AnimationRunnable {
661 protected void animateFrame() {
663 * The pan/zoom controller might have signaled to us that it wants to abort the
664 * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
665 * out.
667 if (mState != PanZoomState.FLING) {
668 finishAnimation();
669 return;
672 /* Advance flings, if necessary. */
673 boolean flingingX = mX.advanceFling();
674 boolean flingingY = mY.advanceFling();
676 boolean overscrolled = (mX.overscrolled() || mY.overscrolled());
678 /* If we're still flinging in any direction, update the origin. */
679 if (flingingX || flingingY) {
680 updatePosition();
683 * Check to see if we're still flinging with an appreciable velocity. The threshold is
684 * higher in the case of overscroll, so we bounce back eagerly when overscrolling but
685 * coast smoothly to a stop when not. In other words, require a greater velocity to
686 * maintain the fling once we enter overscroll.
688 float threshold = (overscrolled && !mSubscroller.scrolling() ? STOPPED_THRESHOLD : FLING_STOPPED_THRESHOLD);
689 if (getVelocity() >= threshold) {
690 mContext.getDocumentOverlay().showPageNumberRect();
691 // we're still flinging
692 return;
695 mX.stopFling();
696 mY.stopFling();
699 /* Perform a bounce-back animation if overscrolled. */
700 if (overscrolled) {
701 bounce();
702 } else {
703 finishAnimation();
704 setState(PanZoomState.NOTHING);
709 private void finishAnimation() {
710 checkMainThread();
712 stopAnimationTimer();
714 mContext.getDocumentOverlay().hidePageNumberRect();
716 // Force a viewport synchronisation
717 mTarget.forceRedraw();
720 /* Returns the nearest viewport metrics with no overscroll visible. */
721 private ImmutableViewportMetrics getValidViewportMetrics() {
722 return getValidViewportMetrics(getMetrics());
725 private ImmutableViewportMetrics getValidViewportMetrics(ImmutableViewportMetrics viewportMetrics) {
726 /* First, we adjust the zoom factor so that we can make no overscrolled area visible. */
727 float zoomFactor = viewportMetrics.zoomFactor;
728 RectF pageRect = viewportMetrics.getPageRect();
729 RectF viewport = viewportMetrics.getViewport();
731 float focusX = viewport.width() / 2.0f;
732 float focusY = viewport.height() / 2.0f;
734 float minZoomFactor = 0.0f;
735 float maxZoomFactor = MAX_ZOOM;
737 ZoomConstraints constraints = mTarget.getZoomConstraints();
738 if (null == constraints) {
739 Log.e(LOGTAG, "ZoomConstraints not available - too impatient?");
740 return viewportMetrics;
743 if (constraints.getMinZoom() > 0)
744 minZoomFactor = constraints.getMinZoom();
745 if (constraints.getMaxZoom() > 0)
746 maxZoomFactor = constraints.getMaxZoom();
748 maxZoomFactor = Math.max(maxZoomFactor, minZoomFactor);
750 if (zoomFactor < minZoomFactor) {
751 // if one (or both) of the page dimensions is smaller than the viewport,
752 // zoom using the top/left as the focus on that axis. this prevents the
753 // scenario where, if both dimensions are smaller than the viewport, but
754 // by different scale factors, we end up scrolled to the end on one axis
755 // after applying the scale
756 PointF center = new PointF(focusX, focusY);
757 viewportMetrics = viewportMetrics.scaleTo(minZoomFactor, center);
758 } else if (zoomFactor > maxZoomFactor) {
759 PointF center = new PointF(viewport.width() / 2.0f, viewport.height() / 2.0f);
760 viewportMetrics = viewportMetrics.scaleTo(maxZoomFactor, center);
763 /* Now we pan to the right origin. */
764 viewportMetrics = viewportMetrics.clamp();
766 viewportMetrics = pushPageToCenterOfViewport(viewportMetrics);
768 return viewportMetrics;
771 private ImmutableViewportMetrics pushPageToCenterOfViewport(ImmutableViewportMetrics viewportMetrics) {
772 RectF pageRect = viewportMetrics.getPageRect();
773 RectF viewportRect = viewportMetrics.getViewport();
775 if (pageRect.width() < viewportRect.width()) {
776 float originX = (viewportRect.width() - pageRect.width()) / 2.0f;
777 viewportMetrics = viewportMetrics.setViewportOrigin(-originX, viewportMetrics.getOrigin().y);
780 if (pageRect.height() < viewportRect.height()) {
781 float originY = (viewportRect.height() - pageRect.height()) / 2.0f;
782 viewportMetrics = viewportMetrics.setViewportOrigin(viewportMetrics.getOrigin().x, -originY);
785 return viewportMetrics;
788 private class AxisX extends Axis {
789 AxisX(SubdocumentScrollHelper subscroller) { super(subscroller); }
790 @Override
791 public float getOrigin() { return getMetrics().viewportRectLeft; }
792 @Override
793 protected float getViewportLength() { return getMetrics().getWidth(); }
794 @Override
795 protected float getPageStart() { return getMetrics().pageRectLeft; }
796 @Override
797 protected float getPageLength() { return getMetrics().getPageWidth(); }
800 private class AxisY extends Axis {
801 AxisY(SubdocumentScrollHelper subscroller) { super(subscroller); }
802 @Override
803 public float getOrigin() { return getMetrics().viewportRectTop; }
804 @Override
805 protected float getViewportLength() { return getMetrics().getHeight(); }
806 @Override
807 protected float getPageStart() { return getMetrics().pageRectTop; }
808 @Override
809 protected float getPageLength() { return getMetrics().getPageHeight(); }
813 * Zooming
815 @Override
816 public boolean onScaleBegin(SimpleScaleGestureDetector detector) {
817 if (mState == PanZoomState.ANIMATED_ZOOM)
818 return false;
820 if (null == mTarget.getZoomConstraints())
821 return false;
823 setState(PanZoomState.PINCHING);
824 mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY());
825 cancelTouch();
827 return true;
830 @Override
831 public boolean onScale(SimpleScaleGestureDetector detector) {
832 if (mTarget.isFullScreen())
833 return false;
835 if (mState != PanZoomState.PINCHING)
836 return false;
838 float prevSpan = detector.getPreviousSpan();
839 if (FloatUtils.fuzzyEquals(prevSpan, 0.0f)) {
840 // let's eat this one to avoid setting the new zoom to infinity (bug 711453)
841 return true;
844 float spanRatio = detector.getCurrentSpan() / prevSpan;
846 synchronized (mTarget.getLock()) {
847 float newZoomFactor = getMetrics().zoomFactor * spanRatio;
848 float minZoomFactor = 0.0f; // deliberately set to zero to allow big zoom out effect
849 float maxZoomFactor = MAX_ZOOM;
851 ZoomConstraints constraints = mTarget.getZoomConstraints();
853 if (constraints.getMaxZoom() > 0)
854 maxZoomFactor = constraints.getMaxZoom();
856 if (newZoomFactor < minZoomFactor) {
857 // apply resistance when zooming past minZoomFactor,
858 // such that it asymptotically reaches minZoomFactor / 2.0
859 // but never exceeds that
860 final float rate = 0.5f; // controls how quickly we approach the limit
861 float excessZoom = minZoomFactor - newZoomFactor;
862 excessZoom = 1.0f - (float)Math.exp(-excessZoom * rate);
863 newZoomFactor = minZoomFactor * (1.0f - excessZoom / 2.0f);
866 if (newZoomFactor > maxZoomFactor) {
867 // apply resistance when zooming past maxZoomFactor,
868 // such that it asymptotically reaches maxZoomFactor + 1.0
869 // but never exceeds that
870 float excessZoom = newZoomFactor - maxZoomFactor;
871 excessZoom = 1.0f - (float)Math.exp(-excessZoom);
872 newZoomFactor = maxZoomFactor + excessZoom;
875 scrollBy(mLastZoomFocus.x - detector.getFocusX(),
876 mLastZoomFocus.y - detector.getFocusY());
877 PointF focus = new PointF(detector.getFocusX(), detector.getFocusY());
878 scaleWithFocus(newZoomFactor, focus);
881 mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY());
883 return true;
886 @Override
887 public void onScaleEnd(SimpleScaleGestureDetector detector) {
888 if (mState == PanZoomState.ANIMATED_ZOOM)
889 return;
891 // switch back to the touching state
892 startTouch(detector.getFocusX(), detector.getFocusY(), detector.getEventTime());
894 // Force a viewport synchronisation
895 mTarget.forceRedraw();
900 * Scales the viewport, keeping the given focus point in the same place before and after the
901 * scale operation. You must hold the monitor while calling this.
903 private void scaleWithFocus(float zoomFactor, PointF focus) {
904 ImmutableViewportMetrics viewportMetrics = getMetrics();
905 viewportMetrics = viewportMetrics.scaleTo(zoomFactor, focus);
906 mTarget.setViewportMetrics(viewportMetrics);
909 public boolean getRedrawHint() {
910 switch (mState) {
911 case PINCHING:
912 case ANIMATED_ZOOM:
913 case BOUNCE:
914 // don't redraw during these because the zoom is (or might be, in the case
915 // of BOUNCE) be changing rapidly and gecko will have to redraw the entire
916 // display port area. we trigger a force-redraw upon exiting these states.
917 return false;
918 default:
919 // allow redrawing in other states
920 return true;
924 @Override
925 public boolean onDown(MotionEvent motionEvent) {
926 mWaitForDoubleTap = mTarget.getZoomConstraints() != null;
927 return false;
930 @Override
931 public void onShowPress(MotionEvent motionEvent) {
932 // If we get this, it will be followed either by a call to
933 // onSingleTapUp (if the user lifts their finger before the
934 // long-press timeout) or a call to onLongPress (if the user
935 // does not). In the former case, we want to make sure it is
936 // treated as a click. (Note that if this is called, we will
937 // not get a call to onDoubleTap).
938 mWaitForDoubleTap = false;
941 private PointF getMotionInDocumentCoordinates(MotionEvent motionEvent) {
942 RectF viewport = getValidViewportMetrics().getViewport();
943 PointF viewPoint = new PointF(motionEvent.getX(0), motionEvent.getY(0));
944 return mTarget.convertViewPointToLayerPoint(viewPoint);
947 @Override
948 public void onLongPress(MotionEvent motionEvent) {
949 LOKitShell.sendTouchEvent("LongPress", getMotionInDocumentCoordinates(motionEvent));
952 @Override
953 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
954 mContext.getDocumentOverlay().showPageNumberRect();
955 return super.onScroll(e1, e2, distanceX, distanceY);
958 @Override
959 public boolean onSingleTapUp(MotionEvent motionEvent) {
960 // When double-tapping is allowed, we have to wait to see if this is
961 // going to be a double-tap.
962 if (!mWaitForDoubleTap) {
963 LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent));
965 // return false because we still want to get the ACTION_UP event that triggers this
966 return false;
969 @Override
970 public boolean onSingleTapConfirmed(MotionEvent motionEvent) {
971 // In cases where we don't wait for double-tap, we handle this in onSingleTapUp.
972 if (mWaitForDoubleTap) {
973 LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent));
975 return true;
978 @Override
979 public boolean onDoubleTap(MotionEvent motionEvent) {
980 if (null == mTarget.getZoomConstraints()) {
981 return true;
983 // Double tap zooms in or out depending on the current zoom factor
984 PointF pointOfTap = getMotionInDocumentCoordinates(motionEvent);
985 ImmutableViewportMetrics metrics = getMetrics();
986 float newZoom = metrics.getZoomFactor() >=
987 DOUBLE_TAP_THRESHOLD ? mTarget.getZoomConstraints().getDefaultZoom() : DOUBLE_TAP_THRESHOLD;
988 // calculate new top_left point from the point of tap
989 float ratio = newZoom/metrics.getZoomFactor();
990 float newLeft = pointOfTap.x - 1/ratio * (pointOfTap.x - metrics.getOrigin().x / metrics.getZoomFactor());
991 float newTop = pointOfTap.y - 1/ratio * (pointOfTap.y - metrics.getOrigin().y / metrics.getZoomFactor());
992 // animate move to the new view
993 animatedMove(new PointF(newLeft, newTop), newZoom);
995 LOKitShell.sendTouchEvent("DoubleTap", pointOfTap);
996 return true;
999 private void cancelTouch() {
1000 //GeckoEvent e = GeckoEvent.createBroadcastEvent("Gesture:CancelTouch", "");
1001 //GeckoAppShell.sendEventToGecko(e);
1005 * Zoom to a specified rect IN CSS PIXELS.
1007 * While we usually use device pixels, zoomToRect must be specified in CSS
1008 * pixels.
1010 boolean animatedZoomTo(RectF zoomToRect) {
1011 final float startZoom = getMetrics().zoomFactor;
1013 RectF viewport = getMetrics().getViewport();
1014 // 1. adjust the aspect ratio of zoomToRect to match that of the current viewport,
1015 // enlarging as necessary (if it gets too big, it will get shrunk in the next step).
1016 // while enlarging make sure we enlarge equally on both sides to keep the target rect
1017 // centered.
1018 float targetRatio = viewport.width() / viewport.height();
1019 float rectRatio = zoomToRect.width() / zoomToRect.height();
1020 if (FloatUtils.fuzzyEquals(targetRatio, rectRatio)) {
1021 // all good, do nothing
1022 } else if (targetRatio < rectRatio) {
1023 // need to increase zoomToRect height
1024 float newHeight = zoomToRect.width() / targetRatio;
1025 zoomToRect.top -= (newHeight - zoomToRect.height()) / 2;
1026 zoomToRect.bottom = zoomToRect.top + newHeight;
1027 } else { // targetRatio > rectRatio) {
1028 // need to increase zoomToRect width
1029 float newWidth = targetRatio * zoomToRect.height();
1030 zoomToRect.left -= (newWidth - zoomToRect.width()) / 2;
1031 zoomToRect.right = zoomToRect.left + newWidth;
1034 float finalZoom = viewport.width() / zoomToRect.width();
1036 ImmutableViewportMetrics finalMetrics = getMetrics();
1037 finalMetrics = finalMetrics.setViewportOrigin(
1038 zoomToRect.left * finalMetrics.zoomFactor,
1039 zoomToRect.top * finalMetrics.zoomFactor);
1040 finalMetrics = finalMetrics.scaleTo(finalZoom, new PointF(0.0f, 0.0f));
1042 // 2. now run getValidViewportMetrics on it, so that the target viewport is
1043 // clamped down to prevent overscroll, over-zoom, and other bad conditions.
1044 finalMetrics = getValidViewportMetrics(finalMetrics);
1046 bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM);
1047 return true;
1051 * Move the viewport to the top-left point to and zoom to the desired
1052 * zoom factor. Input zoom factor can be null, in this case leave the zoom unchanged.
1054 boolean animatedMove(PointF topLeft, Float zoom) {
1055 RectF moveToRect = getMetrics().getCssViewport();
1056 moveToRect.offsetTo(topLeft.x, topLeft.y);
1058 ImmutableViewportMetrics finalMetrics = getMetrics();
1060 finalMetrics = finalMetrics.setViewportOrigin(
1061 moveToRect.left * finalMetrics.zoomFactor,
1062 moveToRect.top * finalMetrics.zoomFactor);
1064 if (zoom != null) {
1065 finalMetrics = finalMetrics.scaleTo(zoom, new PointF(0.0f, 0.0f));
1067 finalMetrics = getValidViewportMetrics(finalMetrics);
1069 bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM);
1070 return true;
1073 /** This function must be called from the UI thread. */
1074 public void abortPanning() {
1075 checkMainThread();
1076 bounce();
1079 public void setOverScrollMode(int overscrollMode) {
1080 mX.setOverScrollMode(overscrollMode);
1081 mY.setOverScrollMode(overscrollMode);
1084 public int getOverScrollMode() {
1085 return mX.getOverScrollMode();