Version 7.6.3.2-android, tag libreoffice-7.6.3.2-android
[LibreOffice.git] / android / source / src / java / org / mozilla / gecko / gfx / JavaPanZoomController.java
blobc0da18ff70bde4f2d98065076318b81d69b5dce9
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;
26 * Handles the kinetic scrolling and zooming physics for a layer controller.
28 * Many ideas are from Joe Hewitt's Scrollability:
29 * https://github.com/joehewitt/scrollability/
31 class JavaPanZoomController
32 extends GestureDetector.SimpleOnGestureListener
33 implements PanZoomController, SimpleScaleGestureDetector.SimpleScaleGestureListener
35 private static final String LOGTAG = "GeckoPanZoomController";
37 // Animation stops if the velocity is below this value when overscrolled or panning.
38 private static final float STOPPED_THRESHOLD = 4.0f;
40 // Animation stops is the velocity is below this threshold when flinging.
41 private static final float FLING_STOPPED_THRESHOLD = 0.1f;
43 // The distance the user has to pan before we recognize it as such (e.g. to avoid 1-pixel pans
44 // between the touch-down and touch-up of a click). In units of density-independent pixels.
45 private final float PAN_THRESHOLD;
47 // Angle from axis within which we stay axis-locked
48 private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees
50 // The maximum amount we allow you to zoom into a page
51 private static final float MAX_ZOOM = 4.0f;
53 // The threshold zoom factor of whether a double tap triggers zoom-in or zoom-out
54 private static final float DOUBLE_TAP_THRESHOLD = 1.0f;
56 // The maximum amount we would like to scroll with the mouse
57 private final float MAX_SCROLL;
59 private enum PanZoomState {
60 NOTHING, /* no touch-start events received */
61 FLING, /* all touches removed, but we're still scrolling page */
62 TOUCHING, /* one touch-start event received */
63 PANNING_LOCKED, /* touch-start followed by move (i.e. panning with axis lock) */
64 PANNING, /* panning without axis lock */
65 PANNING_HOLD, /* in panning, but not moving.
66 * similar to TOUCHING but after starting a pan */
67 PANNING_HOLD_LOCKED, /* like PANNING_HOLD, but axis lock still in effect */
68 PINCHING, /* nth touch-start, where n > 1. this mode allows pan and zoom */
69 ANIMATED_ZOOM, /* animated zoom to a new rect */
70 BOUNCE, /* in a bounce animation */
72 WAITING_LISTENERS, /* a state halfway between NOTHING and TOUCHING - the user has
73 put a finger down, but we don't yet know if a touch listener has
74 prevented the default actions yet. we still need to abort animations. */
77 private final PanZoomTarget mTarget;
78 private final SubdocumentScrollHelper mSubscroller;
79 private final Axis mX;
80 private final Axis mY;
81 private final TouchEventHandler mTouchEventHandler;
82 private Thread mMainThread;
83 private LibreOfficeMainActivity mContext;
85 /* The timer that handles flings or bounces. */
86 private Timer mAnimationTimer;
87 /* The runnable being scheduled by the animation timer. */
88 private AnimationRunnable mAnimationRunnable;
89 /* The zoom focus at the first zoom event (in page coordinates). */
90 private PointF mLastZoomFocus;
91 /* The time the last motion event took place. */
92 private long mLastEventTime;
93 /* Current state the pan/zoom UI is in. */
94 private PanZoomState mState;
95 /* Whether or not to wait for a double-tap before dispatching a single-tap */
96 private boolean mWaitForDoubleTap;
98 JavaPanZoomController(LibreOfficeMainActivity context, PanZoomTarget target, View view) {
99 mContext = context;
100 PAN_THRESHOLD = 1/16f * LOKitShell.getDpi(view.getContext());
101 MAX_SCROLL = 0.075f * LOKitShell.getDpi(view.getContext());
102 mTarget = target;
103 mSubscroller = new SubdocumentScrollHelper();
104 mX = new AxisX(mSubscroller);
105 mY = new AxisY(mSubscroller);
106 mTouchEventHandler = new TouchEventHandler(view.getContext(), view, this);
108 mMainThread = mContext.getMainLooper().getThread();
109 checkMainThread();
111 setState(PanZoomState.NOTHING);
114 public void destroy() {
115 mSubscroller.destroy();
116 mTouchEventHandler.destroy();
119 private static float easeOut(float t) {
120 // ease-out approx.
121 // -(t-1)^2+1
122 t = t-1;
123 return -t*t+1;
126 private void setState(PanZoomState state) {
127 if (state != mState) {
128 mState = state;
132 private ImmutableViewportMetrics getMetrics() {
133 return mTarget.getViewportMetrics();
136 // for debugging bug 713011; it can be taken out once that is resolved.
137 private void checkMainThread() {
138 if (mMainThread != Thread.currentThread()) {
139 // log with full stack trace
140 Log.e(LOGTAG, "Uh-oh, we're running on the wrong thread!", new Exception());
144 /** This function MUST be called on the UI thread */
145 public boolean onMotionEvent(MotionEvent event) {
146 if ((event.getSource() & InputDevice.SOURCE_CLASS_MASK) == InputDevice.SOURCE_CLASS_POINTER
147 && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_SCROLL) {
148 return handlePointerScroll(event);
150 return false;
153 /** This function MUST be called on the UI thread */
154 public boolean onTouchEvent(MotionEvent event) {
155 return mTouchEventHandler.handleEvent(event);
158 boolean handleEvent(MotionEvent event) {
159 switch (event.getAction() & MotionEvent.ACTION_MASK) {
160 case MotionEvent.ACTION_DOWN: return handleTouchStart(event);
161 case MotionEvent.ACTION_MOVE: return handleTouchMove(event);
162 case MotionEvent.ACTION_UP: return handleTouchEnd(event);
163 case MotionEvent.ACTION_CANCEL: return handleTouchCancel(event);
165 return false;
168 /** This function MUST be called on the UI thread */
169 public void notifyDefaultActionPrevented(boolean prevented) {
170 mTouchEventHandler.handleEventListenerAction(!prevented);
173 /** This function must be called from the UI thread. */
174 public void abortAnimation() {
175 checkMainThread();
176 // this happens when gecko changes the viewport on us or if the device is rotated.
177 // if that's the case, abort any animation in progress and re-zoom so that the page
178 // snaps to edges. for other cases (where the user's finger(s) are down) don't do
179 // anything special.
180 switch (mState) {
181 case FLING:
182 mX.stopFling();
183 mY.stopFling();
184 // fall through
185 case BOUNCE:
186 case ANIMATED_ZOOM:
187 // the zoom that's in progress likely makes no sense any more (such as if
188 // the screen orientation changed) so abort it
189 setState(PanZoomState.NOTHING);
190 // fall through
191 case NOTHING:
192 // Don't do animations here; they're distracting and can cause flashes on page
193 // transitions.
194 synchronized (mTarget.getLock()) {
195 mTarget.setViewportMetrics(getValidViewportMetrics());
196 mTarget.forceRedraw();
198 break;
202 /** This function must be called on the UI thread. */
203 void startingNewEventBlock(MotionEvent event, boolean waitingForTouchListeners) {
204 checkMainThread();
205 mSubscroller.cancel();
206 if (waitingForTouchListeners && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) {
207 // this is the first touch point going down, so we enter the pending state
208 // setting the state will kill any animations in progress, possibly leaving
209 // the page in overscroll
210 setState(PanZoomState.WAITING_LISTENERS);
214 /** This function must be called on the UI thread. */
215 void preventedTouchFinished() {
216 checkMainThread();
217 if (mState == PanZoomState.WAITING_LISTENERS) {
218 // if we enter here, we just finished a block of events whose default actions
219 // were prevented by touch listeners. Now there are no touch points left, so
220 // we need to reset our state and re-bounce because we might be in overscroll
221 bounce();
225 /** This must be called on the UI thread. */
226 public void pageRectUpdated() {
227 if (mState == PanZoomState.NOTHING) {
228 synchronized (mTarget.getLock()) {
229 ImmutableViewportMetrics validated = getValidViewportMetrics();
230 if (!getMetrics().fuzzyEquals(validated)) {
231 // page size changed such that we are now in overscroll. snap to
232 // the nearest valid viewport
233 mTarget.setViewportMetrics(validated);
240 * Panning/scrolling
243 private boolean handleTouchStart(MotionEvent event) {
244 // user is taking control of movement, so stop
245 // any auto-movement we have going
246 stopAnimationTimer();
248 switch (mState) {
249 case ANIMATED_ZOOM:
250 // We just interrupted a double-tap animation, so force a redraw in
251 // case this touchstart is just a tap that doesn't end up triggering
252 // a redraw
253 mTarget.forceRedraw();
254 // fall through
255 case FLING:
256 case BOUNCE:
257 case NOTHING:
258 case WAITING_LISTENERS:
259 startTouch(event.getX(0), event.getY(0), event.getEventTime());
260 return false;
261 case TOUCHING:
262 case PANNING:
263 case PANNING_LOCKED:
264 case PANNING_HOLD:
265 case PANNING_HOLD_LOCKED:
266 case PINCHING:
267 Log.e(LOGTAG, "Received impossible touch down while in " + mState);
268 return false;
270 Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchStart");
271 return false;
274 private boolean handleTouchMove(MotionEvent event) {
275 if (mState == PanZoomState.PANNING_LOCKED || mState == PanZoomState.PANNING) {
276 if (getVelocity() > 18.0f) {
277 mContext.hideSoftKeyboard();
281 switch (mState) {
282 case FLING:
283 case BOUNCE:
284 case WAITING_LISTENERS:
285 // should never happen
286 Log.e(LOGTAG, "Received impossible touch move while in " + mState);
287 // fall through
288 case ANIMATED_ZOOM:
289 case NOTHING:
290 // may happen if user double-taps and drags without lifting after the
291 // second tap. ignore the move if this happens.
292 return false;
294 case TOUCHING:
295 // Don't allow panning if there is an element in full-screen mode. See bug 775511.
296 if (mTarget.isFullScreen() || panDistance(event) < PAN_THRESHOLD) {
297 return false;
299 cancelTouch();
300 startPanning(event.getX(0), event.getY(0), event.getEventTime());
301 track(event);
302 return true;
304 case PANNING_HOLD_LOCKED:
305 setState(PanZoomState.PANNING_LOCKED);
306 // fall through
307 case PANNING_LOCKED:
308 track(event);
309 return true;
311 case PANNING_HOLD:
312 setState(PanZoomState.PANNING);
313 // fall through
314 case PANNING:
315 track(event);
316 return true;
318 case PINCHING:
319 // scale gesture listener will handle this
320 return false;
322 Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchMove");
323 return false;
326 private boolean handleTouchEnd(MotionEvent event) {
328 switch (mState) {
329 case FLING:
330 case BOUNCE:
331 case WAITING_LISTENERS:
332 // should never happen
333 Log.e(LOGTAG, "Received impossible touch end while in " + mState);
334 // fall through
335 case ANIMATED_ZOOM:
336 case NOTHING:
337 // may happen if user double-taps and drags without lifting after the
338 // second tap. ignore if this happens.
339 return false;
341 case TOUCHING:
342 // the switch into TOUCHING might have happened while the page was
343 // snapping back after overscroll. we need to finish the snap if that
344 // was the case
345 bounce();
346 return false;
348 case PANNING:
349 case PANNING_LOCKED:
350 case PANNING_HOLD:
351 case PANNING_HOLD_LOCKED:
352 setState(PanZoomState.FLING);
353 fling();
354 return true;
356 case PINCHING:
357 setState(PanZoomState.NOTHING);
358 return true;
360 Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchEnd");
361 return false;
364 private boolean handleTouchCancel(MotionEvent event) {
365 cancelTouch();
367 if (mState == PanZoomState.WAITING_LISTENERS) {
368 // we might get a cancel event from the TouchEventHandler while in the
369 // WAITING_LISTENERS state if the touch listeners prevent-default the
370 // block of events. at this point being in WAITING_LISTENERS is equivalent
371 // to being in NOTHING with the exception of possibly being in overscroll.
372 // so here we don't want to do anything right now; the overscroll will be
373 // corrected in preventedTouchFinished().
374 return false;
377 // ensure we snap back if we're overscrolled
378 bounce();
379 return false;
382 private boolean handlePointerScroll(MotionEvent event) {
383 if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) {
384 float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
385 float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
387 scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL);
388 bounce();
389 return true;
391 return false;
394 private void startTouch(float x, float y, long time) {
395 mX.startTouch(x);
396 mY.startTouch(y);
397 setState(PanZoomState.TOUCHING);
398 mLastEventTime = time;
401 private void startPanning(float x, float y, long time) {
402 float dx = mX.panDistance(x);
403 float dy = mY.panDistance(y);
404 double angle = Math.atan2(dy, dx); // range [-pi, pi]
405 angle = Math.abs(angle); // range [0, pi]
407 // When the touch move breaks through the pan threshold, reposition the touch down origin
408 // so the page won't jump when we start panning.
409 mX.startTouch(x);
410 mY.startTouch(y);
411 mLastEventTime = time;
413 if (!mX.scrollable() || !mY.scrollable()) {
414 setState(PanZoomState.PANNING);
415 } else if (angle < AXIS_LOCK_ANGLE || angle > (Math.PI - AXIS_LOCK_ANGLE)) {
416 mY.setScrollingDisabled(true);
417 setState(PanZoomState.PANNING_LOCKED);
418 } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) {
419 mX.setScrollingDisabled(true);
420 setState(PanZoomState.PANNING_LOCKED);
421 } else {
422 setState(PanZoomState.PANNING);
426 private float panDistance(MotionEvent move) {
427 float dx = mX.panDistance(move.getX(0));
428 float dy = mY.panDistance(move.getY(0));
429 return (float) Math.hypot(dx , dy);
432 private void track(float x, float y, long time) {
433 float timeDelta = (float)(time - mLastEventTime);
434 if (FloatUtils.fuzzyEquals(timeDelta, 0)) {
435 // probably a duplicate event, ignore it. using a zero timeDelta will mess
436 // up our velocity
437 return;
439 mLastEventTime = time;
441 mX.updateWithTouchAt(x, timeDelta);
442 mY.updateWithTouchAt(y, timeDelta);
445 private void track(MotionEvent event) {
446 mX.saveTouchPos();
447 mY.saveTouchPos();
449 for (int i = 0; i < event.getHistorySize(); i++) {
450 track(event.getHistoricalX(0, i),
451 event.getHistoricalY(0, i),
452 event.getHistoricalEventTime(i));
454 track(event.getX(0), event.getY(0), event.getEventTime());
456 if (stopped()) {
457 if (mState == PanZoomState.PANNING) {
458 setState(PanZoomState.PANNING_HOLD);
459 } else if (mState == PanZoomState.PANNING_LOCKED) {
460 setState(PanZoomState.PANNING_HOLD_LOCKED);
461 } else {
462 // should never happen, but handle anyway for robustness
463 Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track");
464 setState(PanZoomState.PANNING_HOLD_LOCKED);
468 mX.startPan();
469 mY.startPan();
470 updatePosition();
473 private void scrollBy(float dx, float dy) {
474 ImmutableViewportMetrics scrolled = getMetrics().offsetViewportBy(dx, dy);
475 mTarget.setViewportMetrics(scrolled);
478 private void fling() {
479 updatePosition();
481 stopAnimationTimer();
483 boolean stopped = stopped();
484 mX.startFling(stopped);
485 mY.startFling(stopped);
487 startAnimationTimer(new FlingRunnable());
490 /* Performs a bounce-back animation to the given viewport metrics. */
491 private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) {
492 stopAnimationTimer();
494 ImmutableViewportMetrics bounceStartMetrics = getMetrics();
495 if (bounceStartMetrics.fuzzyEquals(metrics)) {
496 setState(PanZoomState.NOTHING);
497 finishAnimation();
498 return;
501 setState(state);
503 // At this point we have already set mState to BOUNCE or ANIMATED_ZOOM, so
504 // getRedrawHint() is returning false. This means we can safely call
505 // setAnimationTarget to set the new final display port and not have it get
506 // clobbered by display ports from intermediate animation frames.
507 mTarget.setAnimationTarget(metrics);
508 startAnimationTimer(new BounceRunnable(bounceStartMetrics, metrics));
511 /* Performs a bounce-back animation to the nearest valid viewport metrics. */
512 private void bounce() {
513 bounce(getValidViewportMetrics(), PanZoomState.BOUNCE);
516 /* Starts the fling or bounce animation. */
517 private void startAnimationTimer(final AnimationRunnable runnable) {
518 if (mAnimationTimer != null) {
519 Log.e(LOGTAG, "Attempted to start a new fling without canceling the old one!");
520 stopAnimationTimer();
523 mAnimationTimer = new Timer("Animation Timer");
524 mAnimationRunnable = runnable;
525 mAnimationTimer.scheduleAtFixedRate(new TimerTask() {
526 @Override
527 public void run() { mTarget.post(runnable); }
528 }, 0, (int)Axis.MS_PER_FRAME);
531 /* Stops the fling or bounce animation. */
532 private void stopAnimationTimer() {
533 if (mAnimationTimer != null) {
534 mAnimationTimer.cancel();
535 mAnimationTimer = null;
537 if (mAnimationRunnable != null) {
538 mAnimationRunnable.terminate();
539 mAnimationRunnable = null;
543 private float getVelocity() {
544 float xvel = mX.getRealVelocity();
545 float yvel = mY.getRealVelocity();
546 return (float) Math.sqrt(xvel * xvel + yvel * yvel);
549 public PointF getVelocityVector() {
550 return new PointF(mX.getRealVelocity(), mY.getRealVelocity());
553 private boolean stopped() {
554 return getVelocity() < STOPPED_THRESHOLD;
557 private PointF resetDisplacement() {
558 return new PointF(mX.resetDisplacement(), mY.resetDisplacement());
561 private void updatePosition() {
562 mX.displace();
563 mY.displace();
564 PointF displacement = resetDisplacement();
565 if (FloatUtils.fuzzyEquals(displacement.x, 0.0f) && FloatUtils.fuzzyEquals(displacement.y, 0.0f)) {
566 return;
568 if (! mSubscroller.scrollBy(displacement)) {
569 synchronized (mTarget.getLock()) {
570 scrollBy(displacement.x, displacement.y);
575 private abstract class AnimationRunnable implements Runnable {
576 private boolean mAnimationTerminated;
578 /* This should always run on the UI thread */
579 public final void run() {
581 * Since the animation timer queues this runnable on the UI thread, it
582 * is possible that even when the animation timer is cancelled, there
583 * are multiple instances of this queued, so we need to have another
584 * mechanism to abort. This is done by using the mAnimationTerminated flag.
586 if (mAnimationTerminated) {
587 return;
589 animateFrame();
592 protected abstract void animateFrame();
594 /* This should always run on the UI thread */
595 final void terminate() {
596 mAnimationTerminated = true;
600 /* The callback that performs the bounce animation. */
601 private class BounceRunnable extends AnimationRunnable {
602 /* The current frame of the bounce-back animation */
603 private int mBounceFrame;
605 * The viewport metrics that represent the start and end of the bounce-back animation,
606 * respectively.
608 private ImmutableViewportMetrics mBounceStartMetrics;
609 private ImmutableViewportMetrics mBounceEndMetrics;
611 BounceRunnable(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) {
612 mBounceStartMetrics = startMetrics;
613 mBounceEndMetrics = endMetrics;
616 protected void animateFrame() {
618 * The pan/zoom controller might have signaled to us that it wants to abort the
619 * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
620 * out.
622 if (!(mState == PanZoomState.BOUNCE || mState == PanZoomState.ANIMATED_ZOOM)) {
623 finishAnimation();
624 return;
627 /* Perform the next frame of the bounce-back animation. */
628 if (mBounceFrame < (int)(256f/Axis.MS_PER_FRAME)) {
629 advanceBounce();
630 return;
633 /* Finally, if there's nothing else to do, complete the animation and go to sleep. */
634 finishBounce();
635 finishAnimation();
636 setState(PanZoomState.NOTHING);
639 /* Performs one frame of a bounce animation. */
640 private void advanceBounce() {
641 synchronized (mTarget.getLock()) {
642 float t = easeOut(mBounceFrame * Axis.MS_PER_FRAME / 256f);
643 ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t);
644 mTarget.setViewportMetrics(newMetrics);
645 mBounceFrame++;
649 /* Concludes a bounce animation and snaps the viewport into place. */
650 private void finishBounce() {
651 synchronized (mTarget.getLock()) {
652 mTarget.setViewportMetrics(mBounceEndMetrics);
653 mBounceFrame = -1;
658 // The callback that performs the fling animation.
659 private class FlingRunnable extends AnimationRunnable {
660 protected void animateFrame() {
662 * The pan/zoom controller might have signaled to us that it wants to abort the
663 * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
664 * out.
666 if (mState != PanZoomState.FLING) {
667 finishAnimation();
668 return;
671 /* Advance flings, if necessary. */
672 boolean flingingX = mX.advanceFling();
673 boolean flingingY = mY.advanceFling();
675 boolean overscrolled = (mX.overscrolled() || mY.overscrolled());
677 /* If we're still flinging in any direction, update the origin. */
678 if (flingingX || flingingY) {
679 updatePosition();
682 * Check to see if we're still flinging with an appreciable velocity. The threshold is
683 * higher in the case of overscroll, so we bounce back eagerly when overscrolling but
684 * coast smoothly to a stop when not. In other words, require a greater velocity to
685 * maintain the fling once we enter overscroll.
687 float threshold = (overscrolled && !mSubscroller.scrolling() ? STOPPED_THRESHOLD : FLING_STOPPED_THRESHOLD);
688 if (getVelocity() >= threshold) {
689 mContext.getDocumentOverlay().showPageNumberRect();
690 // we're still flinging
691 return;
694 mX.stopFling();
695 mY.stopFling();
698 /* Perform a bounce-back animation if overscrolled. */
699 if (overscrolled) {
700 bounce();
701 } else {
702 finishAnimation();
703 setState(PanZoomState.NOTHING);
708 private void finishAnimation() {
709 checkMainThread();
711 stopAnimationTimer();
713 mContext.getDocumentOverlay().hidePageNumberRect();
715 // Force a viewport synchronisation
716 mTarget.forceRedraw();
719 /* Returns the nearest viewport metrics with no overscroll visible. */
720 private ImmutableViewportMetrics getValidViewportMetrics() {
721 return getValidViewportMetrics(getMetrics());
724 private ImmutableViewportMetrics getValidViewportMetrics(ImmutableViewportMetrics viewportMetrics) {
725 /* First, we adjust the zoom factor so that we can make no overscrolled area visible. */
726 float zoomFactor = viewportMetrics.zoomFactor;
727 RectF pageRect = viewportMetrics.getPageRect();
728 RectF viewport = viewportMetrics.getViewport();
730 float focusX = viewport.width() / 2.0f;
731 float focusY = viewport.height() / 2.0f;
733 float minZoomFactor = 0.0f;
734 float maxZoomFactor = MAX_ZOOM;
736 ZoomConstraints constraints = mTarget.getZoomConstraints();
737 if (null == constraints) {
738 Log.e(LOGTAG, "ZoomConstraints not available - too impatient?");
739 return viewportMetrics;
742 if (constraints.getMinZoom() > 0)
743 minZoomFactor = constraints.getMinZoom();
744 if (constraints.getMaxZoom() > 0)
745 maxZoomFactor = constraints.getMaxZoom();
747 maxZoomFactor = Math.max(maxZoomFactor, minZoomFactor);
749 if (zoomFactor < minZoomFactor) {
750 // if one (or both) of the page dimensions is smaller than the viewport,
751 // zoom using the top/left as the focus on that axis. this prevents the
752 // scenario where, if both dimensions are smaller than the viewport, but
753 // by different scale factors, we end up scrolled to the end on one axis
754 // after applying the scale
755 PointF center = new PointF(focusX, focusY);
756 viewportMetrics = viewportMetrics.scaleTo(minZoomFactor, center);
757 } else if (zoomFactor > maxZoomFactor) {
758 PointF center = new PointF(viewport.width() / 2.0f, viewport.height() / 2.0f);
759 viewportMetrics = viewportMetrics.scaleTo(maxZoomFactor, center);
762 /* Now we pan to the right origin. */
763 viewportMetrics = viewportMetrics.clamp();
765 viewportMetrics = pushPageToCenterOfViewport(viewportMetrics);
767 return viewportMetrics;
770 private ImmutableViewportMetrics pushPageToCenterOfViewport(ImmutableViewportMetrics viewportMetrics) {
771 RectF pageRect = viewportMetrics.getPageRect();
772 RectF viewportRect = viewportMetrics.getViewport();
774 if (pageRect.width() < viewportRect.width()) {
775 float originX = (viewportRect.width() - pageRect.width()) / 2.0f;
776 viewportMetrics = viewportMetrics.setViewportOrigin(-originX, viewportMetrics.getOrigin().y);
779 if (pageRect.height() < viewportRect.height()) {
780 float originY = (viewportRect.height() - pageRect.height()) / 2.0f;
781 viewportMetrics = viewportMetrics.setViewportOrigin(viewportMetrics.getOrigin().x, -originY);
784 return viewportMetrics;
787 private class AxisX extends Axis {
788 AxisX(SubdocumentScrollHelper subscroller) { super(subscroller); }
789 @Override
790 public float getOrigin() { return getMetrics().viewportRectLeft; }
791 @Override
792 protected float getViewportLength() { return getMetrics().getWidth(); }
793 @Override
794 protected float getPageStart() { return getMetrics().pageRectLeft; }
795 @Override
796 protected float getPageLength() { return getMetrics().getPageWidth(); }
799 private class AxisY extends Axis {
800 AxisY(SubdocumentScrollHelper subscroller) { super(subscroller); }
801 @Override
802 public float getOrigin() { return getMetrics().viewportRectTop; }
803 @Override
804 protected float getViewportLength() { return getMetrics().getHeight(); }
805 @Override
806 protected float getPageStart() { return getMetrics().pageRectTop; }
807 @Override
808 protected float getPageLength() { return getMetrics().getPageHeight(); }
812 * Zooming
814 @Override
815 public boolean onScaleBegin(SimpleScaleGestureDetector detector) {
816 if (mState == PanZoomState.ANIMATED_ZOOM)
817 return false;
819 if (null == mTarget.getZoomConstraints())
820 return false;
822 setState(PanZoomState.PINCHING);
823 mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY());
824 cancelTouch();
826 return true;
829 @Override
830 public boolean onScale(SimpleScaleGestureDetector detector) {
831 if (mTarget.isFullScreen())
832 return false;
834 if (mState != PanZoomState.PINCHING)
835 return false;
837 float prevSpan = detector.getPreviousSpan();
838 if (FloatUtils.fuzzyEquals(prevSpan, 0.0f)) {
839 // let's eat this one to avoid setting the new zoom to infinity (bug 711453)
840 return true;
843 float spanRatio = detector.getCurrentSpan() / prevSpan;
845 synchronized (mTarget.getLock()) {
846 float newZoomFactor = getMetrics().zoomFactor * spanRatio;
847 float minZoomFactor = 0.0f; // deliberately set to zero to allow big zoom out effect
848 float maxZoomFactor = MAX_ZOOM;
850 ZoomConstraints constraints = mTarget.getZoomConstraints();
852 if (constraints.getMaxZoom() > 0)
853 maxZoomFactor = constraints.getMaxZoom();
855 if (newZoomFactor < minZoomFactor) {
856 // apply resistance when zooming past minZoomFactor,
857 // such that it asymptotically reaches minZoomFactor / 2.0
858 // but never exceeds that
859 final float rate = 0.5f; // controls how quickly we approach the limit
860 float excessZoom = minZoomFactor - newZoomFactor;
861 excessZoom = 1.0f - (float)Math.exp(-excessZoom * rate);
862 newZoomFactor = minZoomFactor * (1.0f - excessZoom / 2.0f);
865 if (newZoomFactor > maxZoomFactor) {
866 // apply resistance when zooming past maxZoomFactor,
867 // such that it asymptotically reaches maxZoomFactor + 1.0
868 // but never exceeds that
869 float excessZoom = newZoomFactor - maxZoomFactor;
870 excessZoom = 1.0f - (float)Math.exp(-excessZoom);
871 newZoomFactor = maxZoomFactor + excessZoom;
874 scrollBy(mLastZoomFocus.x - detector.getFocusX(),
875 mLastZoomFocus.y - detector.getFocusY());
876 PointF focus = new PointF(detector.getFocusX(), detector.getFocusY());
877 scaleWithFocus(newZoomFactor, focus);
880 mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY());
882 return true;
885 @Override
886 public void onScaleEnd(SimpleScaleGestureDetector detector) {
887 if (mState == PanZoomState.ANIMATED_ZOOM)
888 return;
890 // switch back to the touching state
891 startTouch(detector.getFocusX(), detector.getFocusY(), detector.getEventTime());
893 // Force a viewport synchronisation
894 mTarget.forceRedraw();
899 * Scales the viewport, keeping the given focus point in the same place before and after the
900 * scale operation. You must hold the monitor while calling this.
902 private void scaleWithFocus(float zoomFactor, PointF focus) {
903 ImmutableViewportMetrics viewportMetrics = getMetrics();
904 viewportMetrics = viewportMetrics.scaleTo(zoomFactor, focus);
905 mTarget.setViewportMetrics(viewportMetrics);
908 public boolean getRedrawHint() {
909 switch (mState) {
910 case PINCHING:
911 case ANIMATED_ZOOM:
912 case BOUNCE:
913 // don't redraw during these because the zoom is (or might be, in the case
914 // of BOUNCE) be changing rapidly and gecko will have to redraw the entire
915 // display port area. we trigger a force-redraw upon exiting these states.
916 return false;
917 default:
918 // allow redrawing in other states
919 return true;
923 @Override
924 public boolean onDown(MotionEvent motionEvent) {
925 mWaitForDoubleTap = mTarget.getZoomConstraints() != null;
926 return false;
929 @Override
930 public void onShowPress(MotionEvent motionEvent) {
931 // If we get this, it will be followed either by a call to
932 // onSingleTapUp (if the user lifts their finger before the
933 // long-press timeout) or a call to onLongPress (if the user
934 // does not). In the former case, we want to make sure it is
935 // treated as a click. (Note that if this is called, we will
936 // not get a call to onDoubleTap).
937 mWaitForDoubleTap = false;
940 private PointF getMotionInDocumentCoordinates(MotionEvent motionEvent) {
941 RectF viewport = getValidViewportMetrics().getViewport();
942 PointF viewPoint = new PointF(motionEvent.getX(0), motionEvent.getY(0));
943 return mTarget.convertViewPointToLayerPoint(viewPoint);
946 @Override
947 public void onLongPress(MotionEvent motionEvent) {
948 LOKitShell.sendTouchEvent("LongPress", getMotionInDocumentCoordinates(motionEvent));
951 @Override
952 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
953 mContext.getDocumentOverlay().showPageNumberRect();
954 return super.onScroll(e1, e2, distanceX, distanceY);
957 @Override
958 public boolean onSingleTapUp(MotionEvent motionEvent) {
959 // When double-tapping is allowed, we have to wait to see if this is
960 // going to be a double-tap.
961 if (!mWaitForDoubleTap) {
962 LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent));
964 // return false because we still want to get the ACTION_UP event that triggers this
965 return false;
968 @Override
969 public boolean onSingleTapConfirmed(MotionEvent motionEvent) {
970 // In cases where we don't wait for double-tap, we handle this in onSingleTapUp.
971 if (mWaitForDoubleTap) {
972 LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent));
974 return true;
977 @Override
978 public boolean onDoubleTap(MotionEvent motionEvent) {
979 if (null == mTarget.getZoomConstraints()) {
980 return true;
982 // Double tap zooms in or out depending on the current zoom factor
983 PointF pointOfTap = getMotionInDocumentCoordinates(motionEvent);
984 ImmutableViewportMetrics metrics = getMetrics();
985 float newZoom = metrics.getZoomFactor() >=
986 DOUBLE_TAP_THRESHOLD ? mTarget.getZoomConstraints().getDefaultZoom() : DOUBLE_TAP_THRESHOLD;
987 // calculate new top_left point from the point of tap
988 float ratio = newZoom/metrics.getZoomFactor();
989 float newLeft = pointOfTap.x - 1/ratio * (pointOfTap.x - metrics.getOrigin().x / metrics.getZoomFactor());
990 float newTop = pointOfTap.y - 1/ratio * (pointOfTap.y - metrics.getOrigin().y / metrics.getZoomFactor());
991 // animate move to the new view
992 animatedMove(new PointF(newLeft, newTop), newZoom);
994 LOKitShell.sendTouchEvent("DoubleTap", pointOfTap);
995 return true;
998 private void cancelTouch() {
999 //GeckoEvent e = GeckoEvent.createBroadcastEvent("Gesture:CancelTouch", "");
1000 //GeckoAppShell.sendEventToGecko(e);
1004 * Zoom to a specified rect IN CSS PIXELS.
1006 * While we usually use device pixels, zoomToRect must be specified in CSS
1007 * pixels.
1009 boolean animatedZoomTo(RectF zoomToRect) {
1010 final float startZoom = getMetrics().zoomFactor;
1012 RectF viewport = getMetrics().getViewport();
1013 // 1. adjust the aspect ratio of zoomToRect to match that of the current viewport,
1014 // enlarging as necessary (if it gets too big, it will get shrunk in the next step).
1015 // while enlarging make sure we enlarge equally on both sides to keep the target rect
1016 // centered.
1017 float targetRatio = viewport.width() / viewport.height();
1018 float rectRatio = zoomToRect.width() / zoomToRect.height();
1019 if (FloatUtils.fuzzyEquals(targetRatio, rectRatio)) {
1020 // all good, do nothing
1021 } else if (targetRatio < rectRatio) {
1022 // need to increase zoomToRect height
1023 float newHeight = zoomToRect.width() / targetRatio;
1024 zoomToRect.top -= (newHeight - zoomToRect.height()) / 2;
1025 zoomToRect.bottom = zoomToRect.top + newHeight;
1026 } else { // targetRatio > rectRatio) {
1027 // need to increase zoomToRect width
1028 float newWidth = targetRatio * zoomToRect.height();
1029 zoomToRect.left -= (newWidth - zoomToRect.width()) / 2;
1030 zoomToRect.right = zoomToRect.left + newWidth;
1033 float finalZoom = viewport.width() / zoomToRect.width();
1035 ImmutableViewportMetrics finalMetrics = getMetrics();
1036 finalMetrics = finalMetrics.setViewportOrigin(
1037 zoomToRect.left * finalMetrics.zoomFactor,
1038 zoomToRect.top * finalMetrics.zoomFactor);
1039 finalMetrics = finalMetrics.scaleTo(finalZoom, new PointF(0.0f, 0.0f));
1041 // 2. now run getValidViewportMetrics on it, so that the target viewport is
1042 // clamped down to prevent overscroll, over-zoom, and other bad conditions.
1043 finalMetrics = getValidViewportMetrics(finalMetrics);
1045 bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM);
1046 return true;
1050 * Move the viewport to the top-left point to and zoom to the desired
1051 * zoom factor. Input zoom factor can be null, in this case leave the zoom unchanged.
1053 boolean animatedMove(PointF topLeft, Float zoom) {
1054 RectF moveToRect = getMetrics().getCssViewport();
1055 moveToRect.offsetTo(topLeft.x, topLeft.y);
1057 ImmutableViewportMetrics finalMetrics = getMetrics();
1059 finalMetrics = finalMetrics.setViewportOrigin(
1060 moveToRect.left * finalMetrics.zoomFactor,
1061 moveToRect.top * finalMetrics.zoomFactor);
1063 if (zoom != null) {
1064 finalMetrics = finalMetrics.scaleTo(zoom, new PointF(0.0f, 0.0f));
1066 finalMetrics = getValidViewportMetrics(finalMetrics);
1068 bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM);
1069 return true;
1072 /** This function must be called from the UI thread. */
1073 public void abortPanning() {
1074 checkMainThread();
1075 bounce();
1078 public void setOverScrollMode(int overscrollMode) {
1079 mX.setOverScrollMode(overscrollMode);
1080 mY.setOverScrollMode(overscrollMode);
1083 public int getOverScrollMode() {
1084 return mX.getOverScrollMode();