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/. */
5 
6 package org.mozilla.gecko.gfx;
7 
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;
16 
17 import org.libreoffice.LOKitShell;
18 import org.libreoffice.LibreOfficeMainActivity;
19 import org.mozilla.gecko.ZoomConstraints;
20 import org.mozilla.gecko.util.FloatUtils;
21 
22 import java.util.Timer;
23 import java.util.TimerTask;
24 
25 /*
26  * Handles the kinetic scrolling and zooming physics for a layer controller.
27  *
28  * Many ideas are from Joe Hewitt's Scrollability:
29  *   https://github.com/joehewitt/scrollability/
30  */
31 class JavaPanZoomController
32         extends GestureDetector.SimpleOnGestureListener
33         implements PanZoomController, SimpleScaleGestureDetector.SimpleScaleGestureListener
34 {
35     private static final String LOGTAG = "GeckoPanZoomController";
36 
37     // Animation stops if the velocity is below this value when overscrolled or panning.
38     private static final float STOPPED_THRESHOLD = 4.0f;
39 
40     // Animation stops is the velocity is below this threshold when flinging.
41     private static final float FLING_STOPPED_THRESHOLD = 0.1f;
42 
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;
46 
47     // Angle from axis within which we stay axis-locked
48     private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees
49 
50     // The maximum amount we allow you to zoom into a page
51     private static final float MAX_ZOOM = 4.0f;
52 
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;
55 
56     // The maximum amount we would like to scroll with the mouse
57     private final float MAX_SCROLL;
58 
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 */
71 
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. */
75     }
76 
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;
84 
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;
97 
JavaPanZoomController(LibreOfficeMainActivity context, PanZoomTarget target, View view)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);
107 
108         mMainThread = mContext.getMainLooper().getThread();
109         checkMainThread();
110 
111         setState(PanZoomState.NOTHING);
112     }
113 
destroy()114     public void destroy() {
115         mSubscroller.destroy();
116         mTouchEventHandler.destroy();
117     }
118 
easeOut(float t)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;
124     }
125 
setState(PanZoomState state)126     private void setState(PanZoomState state) {
127         if (state != mState) {
128             mState = state;
129         }
130     }
131 
getMetrics()132     private ImmutableViewportMetrics getMetrics() {
133         return mTarget.getViewportMetrics();
134     }
135 
136     // for debugging bug 713011; it can be taken out once that is resolved.
checkMainThread()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());
141         }
142     }
143 
144     /** This function MUST be called on the UI thread */
onMotionEvent(MotionEvent event)145     public boolean onMotionEvent(MotionEvent event) {
146         if (Build.VERSION.SDK_INT <= 11) {
147             return false;
148         }
149 
150         switch (event.getSource() & InputDevice.SOURCE_CLASS_MASK) {
151         case InputDevice.SOURCE_CLASS_POINTER:
152             switch (event.getAction() & MotionEvent.ACTION_MASK) {
153             case MotionEvent.ACTION_SCROLL: return handlePointerScroll(event);
154             }
155             break;
156         }
157         return false;
158     }
159 
160     /** This function MUST be called on the UI thread */
onTouchEvent(MotionEvent event)161     public boolean onTouchEvent(MotionEvent event) {
162         return mTouchEventHandler.handleEvent(event);
163     }
164 
handleEvent(MotionEvent event)165     boolean handleEvent(MotionEvent event) {
166         switch (event.getAction() & MotionEvent.ACTION_MASK) {
167         case MotionEvent.ACTION_DOWN:   return handleTouchStart(event);
168         case MotionEvent.ACTION_MOVE:   return handleTouchMove(event);
169         case MotionEvent.ACTION_UP:     return handleTouchEnd(event);
170         case MotionEvent.ACTION_CANCEL: return handleTouchCancel(event);
171         }
172         return false;
173     }
174 
175     /** This function MUST be called on the UI thread */
notifyDefaultActionPrevented(boolean prevented)176     public void notifyDefaultActionPrevented(boolean prevented) {
177         mTouchEventHandler.handleEventListenerAction(!prevented);
178     }
179 
180     /** This function must be called from the UI thread. */
abortAnimation()181     public void abortAnimation() {
182         checkMainThread();
183         // this happens when gecko changes the viewport on us or if the device is rotated.
184         // if that's the case, abort any animation in progress and re-zoom so that the page
185         // snaps to edges. for other cases (where the user's finger(s) are down) don't do
186         // anything special.
187         switch (mState) {
188         case FLING:
189             mX.stopFling();
190             mY.stopFling();
191             // fall through
192         case BOUNCE:
193         case ANIMATED_ZOOM:
194             // the zoom that's in progress likely makes no sense any more (such as if
195             // the screen orientation changed) so abort it
196             setState(PanZoomState.NOTHING);
197             // fall through
198         case NOTHING:
199             // Don't do animations here; they're distracting and can cause flashes on page
200             // transitions.
201             synchronized (mTarget.getLock()) {
202                 mTarget.setViewportMetrics(getValidViewportMetrics());
203                 mTarget.forceRedraw();
204             }
205             break;
206         }
207     }
208 
209     /** This function must be called on the UI thread. */
startingNewEventBlock(MotionEvent event, boolean waitingForTouchListeners)210     void startingNewEventBlock(MotionEvent event, boolean waitingForTouchListeners) {
211         checkMainThread();
212         mSubscroller.cancel();
213         if (waitingForTouchListeners && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) {
214             // this is the first touch point going down, so we enter the pending state
215             // setting the state will kill any animations in progress, possibly leaving
216             // the page in overscroll
217             setState(PanZoomState.WAITING_LISTENERS);
218         }
219     }
220 
221     /** This function must be called on the UI thread. */
preventedTouchFinished()222     void preventedTouchFinished() {
223         checkMainThread();
224         if (mState == PanZoomState.WAITING_LISTENERS) {
225             // if we enter here, we just finished a block of events whose default actions
226             // were prevented by touch listeners. Now there are no touch points left, so
227             // we need to reset our state and re-bounce because we might be in overscroll
228             bounce();
229         }
230     }
231 
232     /** This must be called on the UI thread. */
pageRectUpdated()233     public void pageRectUpdated() {
234         if (mState == PanZoomState.NOTHING) {
235             synchronized (mTarget.getLock()) {
236                 ImmutableViewportMetrics validated = getValidViewportMetrics();
237                 if (!getMetrics().fuzzyEquals(validated)) {
238                     // page size changed such that we are now in overscroll. snap to
239                     // the nearest valid viewport
240                     mTarget.setViewportMetrics(validated);
241                 }
242             }
243         }
244     }
245 
246     /*
247      * Panning/scrolling
248      */
249 
handleTouchStart(MotionEvent event)250     private boolean handleTouchStart(MotionEvent event) {
251         // user is taking control of movement, so stop
252         // any auto-movement we have going
253         stopAnimationTimer();
254 
255         switch (mState) {
256         case ANIMATED_ZOOM:
257             // We just interrupted a double-tap animation, so force a redraw in
258             // case this touchstart is just a tap that doesn't end up triggering
259             // a redraw
260             mTarget.forceRedraw();
261             // fall through
262         case FLING:
263         case BOUNCE:
264         case NOTHING:
265         case WAITING_LISTENERS:
266             startTouch(event.getX(0), event.getY(0), event.getEventTime());
267             return false;
268         case TOUCHING:
269         case PANNING:
270         case PANNING_LOCKED:
271         case PANNING_HOLD:
272         case PANNING_HOLD_LOCKED:
273         case PINCHING:
274             Log.e(LOGTAG, "Received impossible touch down while in " + mState);
275             return false;
276         }
277         Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchStart");
278         return false;
279     }
280 
handleTouchMove(MotionEvent event)281     private boolean handleTouchMove(MotionEvent event) {
282         if (mState == PanZoomState.PANNING_LOCKED || mState == PanZoomState.PANNING) {
283             if (getVelocity() > 18.0f) {
284                 mContext.hideSoftKeyboard();
285             }
286         }
287 
288         switch (mState) {
289         case FLING:
290         case BOUNCE:
291         case WAITING_LISTENERS:
292             // should never happen
293             Log.e(LOGTAG, "Received impossible touch move while in " + mState);
294             // fall through
295         case ANIMATED_ZOOM:
296         case NOTHING:
297             // may happen if user double-taps and drags without lifting after the
298             // second tap. ignore the move if this happens.
299             return false;
300 
301         case TOUCHING:
302             // Don't allow panning if there is an element in full-screen mode. See bug 775511.
303             if (mTarget.isFullScreen() || panDistance(event) < PAN_THRESHOLD) {
304                 return false;
305             }
306             cancelTouch();
307             startPanning(event.getX(0), event.getY(0), event.getEventTime());
308             track(event);
309             return true;
310 
311         case PANNING_HOLD_LOCKED:
312             setState(PanZoomState.PANNING_LOCKED);
313             // fall through
314         case PANNING_LOCKED:
315             track(event);
316             return true;
317 
318         case PANNING_HOLD:
319             setState(PanZoomState.PANNING);
320             // fall through
321         case PANNING:
322             track(event);
323             return true;
324 
325         case PINCHING:
326             // scale gesture listener will handle this
327             return false;
328         }
329         Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchMove");
330         return false;
331     }
332 
handleTouchEnd(MotionEvent event)333     private boolean handleTouchEnd(MotionEvent event) {
334 
335         switch (mState) {
336         case FLING:
337         case BOUNCE:
338         case WAITING_LISTENERS:
339             // should never happen
340             Log.e(LOGTAG, "Received impossible touch end while in " + mState);
341             // fall through
342         case ANIMATED_ZOOM:
343         case NOTHING:
344             // may happen if user double-taps and drags without lifting after the
345             // second tap. ignore if this happens.
346             return false;
347 
348         case TOUCHING:
349             // the switch into TOUCHING might have happened while the page was
350             // snapping back after overscroll. we need to finish the snap if that
351             // was the case
352             bounce();
353             return false;
354 
355         case PANNING:
356         case PANNING_LOCKED:
357         case PANNING_HOLD:
358         case PANNING_HOLD_LOCKED:
359             setState(PanZoomState.FLING);
360             fling();
361             return true;
362 
363         case PINCHING:
364             setState(PanZoomState.NOTHING);
365             return true;
366         }
367         Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchEnd");
368         return false;
369     }
370 
handleTouchCancel(MotionEvent event)371     private boolean handleTouchCancel(MotionEvent event) {
372         cancelTouch();
373 
374         if (mState == PanZoomState.WAITING_LISTENERS) {
375             // we might get a cancel event from the TouchEventHandler while in the
376             // WAITING_LISTENERS state if the touch listeners prevent-default the
377             // block of events. at this point being in WAITING_LISTENERS is equivalent
378             // to being in NOTHING with the exception of possibly being in overscroll.
379             // so here we don't want to do anything right now; the overscroll will be
380             // corrected in preventedTouchFinished().
381             return false;
382         }
383 
384         // ensure we snap back if we're overscrolled
385         bounce();
386         return false;
387     }
388 
handlePointerScroll(MotionEvent event)389     private boolean handlePointerScroll(MotionEvent event) {
390         if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) {
391             float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
392             float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
393 
394             scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL);
395             bounce();
396             return true;
397         }
398         return false;
399     }
400 
startTouch(float x, float y, long time)401     private void startTouch(float x, float y, long time) {
402         mX.startTouch(x);
403         mY.startTouch(y);
404         setState(PanZoomState.TOUCHING);
405         mLastEventTime = time;
406     }
407 
startPanning(float x, float y, long time)408     private void startPanning(float x, float y, long time) {
409         float dx = mX.panDistance(x);
410         float dy = mY.panDistance(y);
411         double angle = Math.atan2(dy, dx); // range [-pi, pi]
412         angle = Math.abs(angle); // range [0, pi]
413 
414         // When the touch move breaks through the pan threshold, reposition the touch down origin
415         // so the page won't jump when we start panning.
416         mX.startTouch(x);
417         mY.startTouch(y);
418         mLastEventTime = time;
419 
420         if (!mX.scrollable() || !mY.scrollable()) {
421             setState(PanZoomState.PANNING);
422         } else if (angle < AXIS_LOCK_ANGLE || angle > (Math.PI - AXIS_LOCK_ANGLE)) {
423             mY.setScrollingDisabled(true);
424             setState(PanZoomState.PANNING_LOCKED);
425         } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) {
426             mX.setScrollingDisabled(true);
427             setState(PanZoomState.PANNING_LOCKED);
428         } else {
429             setState(PanZoomState.PANNING);
430         }
431     }
432 
panDistance(MotionEvent move)433     private float panDistance(MotionEvent move) {
434         float dx = mX.panDistance(move.getX(0));
435         float dy = mY.panDistance(move.getY(0));
436         return (float) Math.sqrt(dx * dx + dy * dy);
437     }
438 
track(float x, float y, long time)439     private void track(float x, float y, long time) {
440         float timeDelta = (float)(time - mLastEventTime);
441         if (FloatUtils.fuzzyEquals(timeDelta, 0)) {
442             // probably a duplicate event, ignore it. using a zero timeDelta will mess
443             // up our velocity
444             return;
445         }
446         mLastEventTime = time;
447 
448         mX.updateWithTouchAt(x, timeDelta);
449         mY.updateWithTouchAt(y, timeDelta);
450     }
451 
track(MotionEvent event)452     private void track(MotionEvent event) {
453         mX.saveTouchPos();
454         mY.saveTouchPos();
455 
456         for (int i = 0; i < event.getHistorySize(); i++) {
457             track(event.getHistoricalX(0, i),
458                   event.getHistoricalY(0, i),
459                   event.getHistoricalEventTime(i));
460         }
461         track(event.getX(0), event.getY(0), event.getEventTime());
462 
463         if (stopped()) {
464             if (mState == PanZoomState.PANNING) {
465                 setState(PanZoomState.PANNING_HOLD);
466             } else if (mState == PanZoomState.PANNING_LOCKED) {
467                 setState(PanZoomState.PANNING_HOLD_LOCKED);
468             } else {
469                 // should never happen, but handle anyway for robustness
470                 Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track");
471                 setState(PanZoomState.PANNING_HOLD_LOCKED);
472             }
473         }
474 
475         mX.startPan();
476         mY.startPan();
477         updatePosition();
478     }
479 
scrollBy(float dx, float dy)480     private void scrollBy(float dx, float dy) {
481         ImmutableViewportMetrics scrolled = getMetrics().offsetViewportBy(dx, dy);
482         mTarget.setViewportMetrics(scrolled);
483     }
484 
fling()485     private void fling() {
486         updatePosition();
487 
488         stopAnimationTimer();
489 
490         boolean stopped = stopped();
491         mX.startFling(stopped);
492         mY.startFling(stopped);
493 
494         startAnimationTimer(new FlingRunnable());
495     }
496 
497     /* Performs a bounce-back animation to the given viewport metrics. */
bounce(ImmutableViewportMetrics metrics, PanZoomState state)498     private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) {
499         stopAnimationTimer();
500 
501         ImmutableViewportMetrics bounceStartMetrics = getMetrics();
502         if (bounceStartMetrics.fuzzyEquals(metrics)) {
503             setState(PanZoomState.NOTHING);
504             finishAnimation();
505             return;
506         }
507 
508         setState(state);
509 
510         // At this point we have already set mState to BOUNCE or ANIMATED_ZOOM, so
511         // getRedrawHint() is returning false. This means we can safely call
512         // setAnimationTarget to set the new final display port and not have it get
513         // clobbered by display ports from intermediate animation frames.
514         mTarget.setAnimationTarget(metrics);
515         startAnimationTimer(new BounceRunnable(bounceStartMetrics, metrics));
516     }
517 
518     /* Performs a bounce-back animation to the nearest valid viewport metrics. */
bounce()519     private void bounce() {
520         bounce(getValidViewportMetrics(), PanZoomState.BOUNCE);
521     }
522 
523     /* Starts the fling or bounce animation. */
startAnimationTimer(final AnimationRunnable runnable)524     private void startAnimationTimer(final AnimationRunnable runnable) {
525         if (mAnimationTimer != null) {
526             Log.e(LOGTAG, "Attempted to start a new fling without canceling the old one!");
527             stopAnimationTimer();
528         }
529 
530         mAnimationTimer = new Timer("Animation Timer");
531         mAnimationRunnable = runnable;
532         mAnimationTimer.scheduleAtFixedRate(new TimerTask() {
533             @Override
534             public void run() { mTarget.post(runnable); }
535         }, 0, (int)Axis.MS_PER_FRAME);
536     }
537 
538     /* Stops the fling or bounce animation. */
stopAnimationTimer()539     private void stopAnimationTimer() {
540         if (mAnimationTimer != null) {
541             mAnimationTimer.cancel();
542             mAnimationTimer = null;
543         }
544         if (mAnimationRunnable != null) {
545             mAnimationRunnable.terminate();
546             mAnimationRunnable = null;
547         }
548     }
549 
getVelocity()550     private float getVelocity() {
551         float xvel = mX.getRealVelocity();
552         float yvel = mY.getRealVelocity();
553         return (float) Math.sqrt(xvel * xvel + yvel * yvel);
554     }
555 
getVelocityVector()556     public PointF getVelocityVector() {
557         return new PointF(mX.getRealVelocity(), mY.getRealVelocity());
558     }
559 
stopped()560     private boolean stopped() {
561         return getVelocity() < STOPPED_THRESHOLD;
562     }
563 
resetDisplacement()564     private PointF resetDisplacement() {
565         return new PointF(mX.resetDisplacement(), mY.resetDisplacement());
566     }
567 
updatePosition()568     private void updatePosition() {
569         mX.displace();
570         mY.displace();
571         PointF displacement = resetDisplacement();
572         if (FloatUtils.fuzzyEquals(displacement.x, 0.0f) && FloatUtils.fuzzyEquals(displacement.y, 0.0f)) {
573             return;
574         }
575         if (! mSubscroller.scrollBy(displacement)) {
576             synchronized (mTarget.getLock()) {
577                 scrollBy(displacement.x, displacement.y);
578             }
579         }
580     }
581 
582     private abstract class AnimationRunnable implements Runnable {
583         private boolean mAnimationTerminated;
584 
585         /* This should always run on the UI thread */
run()586         public final void run() {
587             /*
588              * Since the animation timer queues this runnable on the UI thread, it
589              * is possible that even when the animation timer is cancelled, there
590              * are multiple instances of this queued, so we need to have another
591              * mechanism to abort. This is done by using the mAnimationTerminated flag.
592              */
593             if (mAnimationTerminated) {
594                 return;
595             }
596             animateFrame();
597         }
598 
animateFrame()599         protected abstract void animateFrame();
600 
601         /* This should always run on the UI thread */
terminate()602         final void terminate() {
603             mAnimationTerminated = true;
604         }
605     }
606 
607     /* The callback that performs the bounce animation. */
608     private class BounceRunnable extends AnimationRunnable {
609         /* The current frame of the bounce-back animation */
610         private int mBounceFrame;
611         /*
612          * The viewport metrics that represent the start and end of the bounce-back animation,
613          * respectively.
614          */
615         private ImmutableViewportMetrics mBounceStartMetrics;
616         private ImmutableViewportMetrics mBounceEndMetrics;
617 
BounceRunnable(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics)618         BounceRunnable(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) {
619             mBounceStartMetrics = startMetrics;
620             mBounceEndMetrics = endMetrics;
621         }
622 
animateFrame()623         protected void animateFrame() {
624             /*
625              * The pan/zoom controller might have signaled to us that it wants to abort the
626              * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
627              * out.
628              */
629             if (!(mState == PanZoomState.BOUNCE || mState == PanZoomState.ANIMATED_ZOOM)) {
630                 finishAnimation();
631                 return;
632             }
633 
634             /* Perform the next frame of the bounce-back animation. */
635             if (mBounceFrame < (int)(256f/Axis.MS_PER_FRAME)) {
636                 advanceBounce();
637                 return;
638             }
639 
640             /* Finally, if there's nothing else to do, complete the animation and go to sleep. */
641             finishBounce();
642             finishAnimation();
643             setState(PanZoomState.NOTHING);
644         }
645 
646         /* Performs one frame of a bounce animation. */
advanceBounce()647         private void advanceBounce() {
648             synchronized (mTarget.getLock()) {
649                 float t = easeOut(mBounceFrame * Axis.MS_PER_FRAME / 256f);
650                 ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t);
651                 mTarget.setViewportMetrics(newMetrics);
652                 mBounceFrame++;
653             }
654         }
655 
656         /* Concludes a bounce animation and snaps the viewport into place. */
finishBounce()657         private void finishBounce() {
658             synchronized (mTarget.getLock()) {
659                 mTarget.setViewportMetrics(mBounceEndMetrics);
660                 mBounceFrame = -1;
661             }
662         }
663     }
664 
665     // The callback that performs the fling animation.
666     private class FlingRunnable extends AnimationRunnable {
animateFrame()667         protected void animateFrame() {
668             /*
669              * The pan/zoom controller might have signaled to us that it wants to abort the
670              * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
671              * out.
672              */
673             if (mState != PanZoomState.FLING) {
674                 finishAnimation();
675                 return;
676             }
677 
678             /* Advance flings, if necessary. */
679             boolean flingingX = mX.advanceFling();
680             boolean flingingY = mY.advanceFling();
681 
682             boolean overscrolled = (mX.overscrolled() || mY.overscrolled());
683 
684             /* If we're still flinging in any direction, update the origin. */
685             if (flingingX || flingingY) {
686                 updatePosition();
687 
688                 /*
689                  * Check to see if we're still flinging with an appreciable velocity. The threshold is
690                  * higher in the case of overscroll, so we bounce back eagerly when overscrolling but
691                  * coast smoothly to a stop when not. In other words, require a greater velocity to
692                  * maintain the fling once we enter overscroll.
693                  */
694                 float threshold = (overscrolled && !mSubscroller.scrolling() ? STOPPED_THRESHOLD : FLING_STOPPED_THRESHOLD);
695                 if (getVelocity() >= threshold) {
696                     mContext.getDocumentOverlay().showPageNumberRect();
697                     // we're still flinging
698                     return;
699                 }
700 
701                 mX.stopFling();
702                 mY.stopFling();
703             }
704 
705             /* Perform a bounce-back animation if overscrolled. */
706             if (overscrolled) {
707                 bounce();
708             } else {
709                 finishAnimation();
710                 setState(PanZoomState.NOTHING);
711             }
712         }
713     }
714 
finishAnimation()715     private void finishAnimation() {
716         checkMainThread();
717 
718         stopAnimationTimer();
719 
720         mContext.getDocumentOverlay().hidePageNumberRect();
721 
722         // Force a viewport synchronisation
723         mTarget.forceRedraw();
724     }
725 
726     /* Returns the nearest viewport metrics with no overscroll visible. */
getValidViewportMetrics()727     private ImmutableViewportMetrics getValidViewportMetrics() {
728         return getValidViewportMetrics(getMetrics());
729     }
730 
getValidViewportMetrics(ImmutableViewportMetrics viewportMetrics)731     private ImmutableViewportMetrics getValidViewportMetrics(ImmutableViewportMetrics viewportMetrics) {
732         /* First, we adjust the zoom factor so that we can make no overscrolled area visible. */
733         float zoomFactor = viewportMetrics.zoomFactor;
734         RectF pageRect = viewportMetrics.getPageRect();
735         RectF viewport = viewportMetrics.getViewport();
736 
737         float focusX = viewport.width() / 2.0f;
738         float focusY = viewport.height() / 2.0f;
739 
740         float minZoomFactor = 0.0f;
741         float maxZoomFactor = MAX_ZOOM;
742 
743         ZoomConstraints constraints = mTarget.getZoomConstraints();
744         if (null == constraints) {
745             Log.e(LOGTAG, "ZoomConstraints not available - too impatient?");
746             return viewportMetrics;
747 
748         }
749         if (constraints.getMinZoom() > 0)
750             minZoomFactor = constraints.getMinZoom();
751         if (constraints.getMaxZoom() > 0)
752             maxZoomFactor = constraints.getMaxZoom();
753 
754         if (!constraints.getAllowZoom()) {
755             // If allowZoom is false, clamp to the default zoom level.
756             maxZoomFactor = minZoomFactor = constraints.getDefaultZoom();
757         }
758 
759         maxZoomFactor = Math.max(maxZoomFactor, minZoomFactor);
760 
761         if (zoomFactor < minZoomFactor) {
762             // if one (or both) of the page dimensions is smaller than the viewport,
763             // zoom using the top/left as the focus on that axis. this prevents the
764             // scenario where, if both dimensions are smaller than the viewport, but
765             // by different scale factors, we end up scrolled to the end on one axis
766             // after applying the scale
767             PointF center = new PointF(focusX, focusY);
768             viewportMetrics = viewportMetrics.scaleTo(minZoomFactor, center);
769         } else if (zoomFactor > maxZoomFactor) {
770             PointF center = new PointF(viewport.width() / 2.0f, viewport.height() / 2.0f);
771             viewportMetrics = viewportMetrics.scaleTo(maxZoomFactor, center);
772         }
773 
774         /* Now we pan to the right origin. */
775         viewportMetrics = viewportMetrics.clamp();
776 
777         viewportMetrics = pushPageToCenterOfViewport(viewportMetrics);
778 
779         return viewportMetrics;
780     }
781 
pushPageToCenterOfViewport(ImmutableViewportMetrics viewportMetrics)782     private ImmutableViewportMetrics pushPageToCenterOfViewport(ImmutableViewportMetrics viewportMetrics) {
783         RectF pageRect = viewportMetrics.getPageRect();
784         RectF viewportRect = viewportMetrics.getViewport();
785 
786         if (pageRect.width() < viewportRect.width()) {
787             float originX = (viewportRect.width() - pageRect.width()) / 2.0f;
788             viewportMetrics = viewportMetrics.setViewportOrigin(-originX, viewportMetrics.getOrigin().y);
789         }
790 
791         if (pageRect.height() < viewportRect.height()) {
792             float originY = (viewportRect.height() - pageRect.height()) / 2.0f;
793             viewportMetrics = viewportMetrics.setViewportOrigin(viewportMetrics.getOrigin().x, -originY);
794         }
795 
796         return viewportMetrics;
797     }
798 
799     private class AxisX extends Axis {
AxisX(SubdocumentScrollHelper subscroller)800         AxisX(SubdocumentScrollHelper subscroller) { super(subscroller); }
801         @Override
getOrigin()802         public float getOrigin() { return getMetrics().viewportRectLeft; }
803         @Override
getViewportLength()804         protected float getViewportLength() { return getMetrics().getWidth(); }
805         @Override
getPageStart()806         protected float getPageStart() { return getMetrics().pageRectLeft; }
807         @Override
getPageLength()808         protected float getPageLength() { return getMetrics().getPageWidth(); }
809     }
810 
811     private class AxisY extends Axis {
AxisY(SubdocumentScrollHelper subscroller)812         AxisY(SubdocumentScrollHelper subscroller) { super(subscroller); }
813         @Override
getOrigin()814         public float getOrigin() { return getMetrics().viewportRectTop; }
815         @Override
getViewportLength()816         protected float getViewportLength() { return getMetrics().getHeight(); }
817         @Override
getPageStart()818         protected float getPageStart() { return getMetrics().pageRectTop; }
819         @Override
getPageLength()820         protected float getPageLength() { return getMetrics().getPageHeight(); }
821     }
822 
823     /*
824      * Zooming
825      */
826     @Override
onScaleBegin(SimpleScaleGestureDetector detector)827     public boolean onScaleBegin(SimpleScaleGestureDetector detector) {
828         if (mState == PanZoomState.ANIMATED_ZOOM)
829             return false;
830 
831         if (null == mTarget.getZoomConstraints() || !mTarget.getZoomConstraints().getAllowZoom())
832             return false;
833 
834         setState(PanZoomState.PINCHING);
835         mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY());
836         cancelTouch();
837 
838         return true;
839     }
840 
841     @Override
onScale(SimpleScaleGestureDetector detector)842     public boolean onScale(SimpleScaleGestureDetector detector) {
843         if (mTarget.isFullScreen())
844             return false;
845 
846         if (mState != PanZoomState.PINCHING)
847             return false;
848 
849         float prevSpan = detector.getPreviousSpan();
850         if (FloatUtils.fuzzyEquals(prevSpan, 0.0f)) {
851             // let's eat this one to avoid setting the new zoom to infinity (bug 711453)
852             return true;
853         }
854 
855         float spanRatio = detector.getCurrentSpan() / prevSpan;
856 
857         synchronized (mTarget.getLock()) {
858             float newZoomFactor = getMetrics().zoomFactor * spanRatio;
859             float minZoomFactor = 0.0f; // deliberately set to zero to allow big zoom out effect
860             float maxZoomFactor = MAX_ZOOM;
861 
862             ZoomConstraints constraints = mTarget.getZoomConstraints();
863 
864             if (constraints.getMaxZoom() > 0)
865                 maxZoomFactor = constraints.getMaxZoom();
866 
867             if (newZoomFactor < minZoomFactor) {
868                 // apply resistance when zooming past minZoomFactor,
869                 // such that it asymptotically reaches minZoomFactor / 2.0
870                 // but never exceeds that
871                 final float rate = 0.5f; // controls how quickly we approach the limit
872                 float excessZoom = minZoomFactor - newZoomFactor;
873                 excessZoom = 1.0f - (float)Math.exp(-excessZoom * rate);
874                 newZoomFactor = minZoomFactor * (1.0f - excessZoom / 2.0f);
875             }
876 
877             if (newZoomFactor > maxZoomFactor) {
878                 // apply resistance when zooming past maxZoomFactor,
879                 // such that it asymptotically reaches maxZoomFactor + 1.0
880                 // but never exceeds that
881                 float excessZoom = newZoomFactor - maxZoomFactor;
882                 excessZoom = 1.0f - (float)Math.exp(-excessZoom);
883                 newZoomFactor = maxZoomFactor + excessZoom;
884             }
885 
886             scrollBy(mLastZoomFocus.x - detector.getFocusX(),
887                      mLastZoomFocus.y - detector.getFocusY());
888             PointF focus = new PointF(detector.getFocusX(), detector.getFocusY());
889             scaleWithFocus(newZoomFactor, focus);
890         }
891 
892         mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY());
893 
894         return true;
895     }
896 
897     @Override
onScaleEnd(SimpleScaleGestureDetector detector)898     public void onScaleEnd(SimpleScaleGestureDetector detector) {
899         if (mState == PanZoomState.ANIMATED_ZOOM)
900             return;
901 
902         // switch back to the touching state
903         startTouch(detector.getFocusX(), detector.getFocusY(), detector.getEventTime());
904 
905         // Force a viewport synchronisation
906         mTarget.forceRedraw();
907 
908     }
909 
910     /**
911      * Scales the viewport, keeping the given focus point in the same place before and after the
912      * scale operation. You must hold the monitor while calling this.
913      */
scaleWithFocus(float zoomFactor, PointF focus)914     private void scaleWithFocus(float zoomFactor, PointF focus) {
915         ImmutableViewportMetrics viewportMetrics = getMetrics();
916         viewportMetrics = viewportMetrics.scaleTo(zoomFactor, focus);
917         mTarget.setViewportMetrics(viewportMetrics);
918     }
919 
getRedrawHint()920     public boolean getRedrawHint() {
921         switch (mState) {
922             case PINCHING:
923             case ANIMATED_ZOOM:
924             case BOUNCE:
925                 // don't redraw during these because the zoom is (or might be, in the case
926                 // of BOUNCE) be changing rapidly and gecko will have to redraw the entire
927                 // display port area. we trigger a force-redraw upon exiting these states.
928                 return false;
929             default:
930                 // allow redrawing in other states
931                 return true;
932         }
933     }
934 
935     @Override
onDown(MotionEvent motionEvent)936     public boolean onDown(MotionEvent motionEvent) {
937         if (mTarget.getZoomConstraints() != null)
938             mWaitForDoubleTap = mTarget.getZoomConstraints().getAllowDoubleTapZoom();
939         else
940             mWaitForDoubleTap = false;
941         return false;
942     }
943 
944     @Override
onShowPress(MotionEvent motionEvent)945     public void onShowPress(MotionEvent motionEvent) {
946         // If we get this, it will be followed either by a call to
947         // onSingleTapUp (if the user lifts their finger before the
948         // long-press timeout) or a call to onLongPress (if the user
949         // does not). In the former case, we want to make sure it is
950         // treated as a click. (Note that if this is called, we will
951         // not get a call to onDoubleTap).
952         mWaitForDoubleTap = false;
953     }
954 
getMotionInDocumentCoordinates(MotionEvent motionEvent)955     private PointF getMotionInDocumentCoordinates(MotionEvent motionEvent) {
956         RectF viewport = getValidViewportMetrics().getViewport();
957         PointF viewPoint = new PointF(motionEvent.getX(0), motionEvent.getY(0));
958         return mTarget.convertViewPointToLayerPoint(viewPoint);
959     }
960 
961     @Override
onLongPress(MotionEvent motionEvent)962     public void onLongPress(MotionEvent motionEvent) {
963         LOKitShell.sendTouchEvent("LongPress", getMotionInDocumentCoordinates(motionEvent));
964     }
965 
966     @Override
onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)967     public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
968         mContext.getDocumentOverlay().showPageNumberRect();
969         return super.onScroll(e1, e2, distanceX, distanceY);
970     }
971 
972     @Override
onSingleTapUp(MotionEvent motionEvent)973     public boolean onSingleTapUp(MotionEvent motionEvent) {
974         // When double-tapping is allowed, we have to wait to see if this is
975         // going to be a double-tap.
976         if (!mWaitForDoubleTap) {
977             LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent));
978         }
979         // return false because we still want to get the ACTION_UP event that triggers this
980         return false;
981     }
982 
983     @Override
onSingleTapConfirmed(MotionEvent motionEvent)984     public boolean onSingleTapConfirmed(MotionEvent motionEvent) {
985         // In cases where we don't wait for double-tap, we handle this in onSingleTapUp.
986         if (mWaitForDoubleTap) {
987             LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent));
988         }
989         return true;
990     }
991 
992     @Override
onDoubleTap(MotionEvent motionEvent)993     public boolean onDoubleTap(MotionEvent motionEvent) {
994         if (null == mTarget.getZoomConstraints() || !mTarget.getZoomConstraints().getAllowDoubleTapZoom()) {
995             return true;
996         }
997         // Double tap zooms in or out depending on the current zoom factor
998         PointF pointOfTap = getMotionInDocumentCoordinates(motionEvent);
999         ImmutableViewportMetrics metrics = getMetrics();
1000         float newZoom = metrics.getZoomFactor() >=
1001                 DOUBLE_TAP_THRESHOLD ? mTarget.getZoomConstraints().getMinZoom() : DOUBLE_TAP_THRESHOLD;
1002         // calculate new top_left point from the point of tap
1003         float ratio = newZoom/metrics.getZoomFactor();
1004         float newLeft = pointOfTap.x - 1/ratio * (pointOfTap.x - metrics.getOrigin().x / metrics.getZoomFactor());
1005         float newTop = pointOfTap.y - 1/ratio * (pointOfTap.y - metrics.getOrigin().y / metrics.getZoomFactor());
1006         // animate move to the new view
1007         animatedMove(new PointF(newLeft, newTop), newZoom);
1008 
1009         LOKitShell.sendTouchEvent("DoubleTap", pointOfTap);
1010         return true;
1011     }
1012 
cancelTouch()1013     private void cancelTouch() {
1014         //GeckoEvent e = GeckoEvent.createBroadcastEvent("Gesture:CancelTouch", "");
1015         //GeckoAppShell.sendEventToGecko(e);
1016     }
1017 
1018     /**
1019      * Zoom to a specified rect IN CSS PIXELS.
1020      *
1021      * While we usually use device pixels, zoomToRect must be specified in CSS
1022      * pixels.
1023      */
animatedZoomTo(RectF zoomToRect)1024     boolean animatedZoomTo(RectF zoomToRect) {
1025         final float startZoom = getMetrics().zoomFactor;
1026 
1027         RectF viewport = getMetrics().getViewport();
1028         // 1. adjust the aspect ratio of zoomToRect to match that of the current viewport,
1029         // enlarging as necessary (if it gets too big, it will get shrunk in the next step).
1030         // while enlarging make sure we enlarge equally on both sides to keep the target rect
1031         // centered.
1032         float targetRatio = viewport.width() / viewport.height();
1033         float rectRatio = zoomToRect.width() / zoomToRect.height();
1034         if (FloatUtils.fuzzyEquals(targetRatio, rectRatio)) {
1035             // all good, do nothing
1036         } else if (targetRatio < rectRatio) {
1037             // need to increase zoomToRect height
1038             float newHeight = zoomToRect.width() / targetRatio;
1039             zoomToRect.top -= (newHeight - zoomToRect.height()) / 2;
1040             zoomToRect.bottom = zoomToRect.top + newHeight;
1041         } else { // targetRatio > rectRatio) {
1042             // need to increase zoomToRect width
1043             float newWidth = targetRatio * zoomToRect.height();
1044             zoomToRect.left -= (newWidth - zoomToRect.width()) / 2;
1045             zoomToRect.right = zoomToRect.left + newWidth;
1046         }
1047 
1048         float finalZoom = viewport.width() / zoomToRect.width();
1049 
1050         ImmutableViewportMetrics finalMetrics = getMetrics();
1051         finalMetrics = finalMetrics.setViewportOrigin(
1052             zoomToRect.left * finalMetrics.zoomFactor,
1053             zoomToRect.top * finalMetrics.zoomFactor);
1054         finalMetrics = finalMetrics.scaleTo(finalZoom, new PointF(0.0f, 0.0f));
1055 
1056         // 2. now run getValidViewportMetrics on it, so that the target viewport is
1057         // clamped down to prevent overscroll, over-zoom, and other bad conditions.
1058         finalMetrics = getValidViewportMetrics(finalMetrics);
1059 
1060         bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM);
1061         return true;
1062     }
1063 
1064     /**
1065      * Move the viewport to the top-left point to and zoom to the desired
1066      * zoom factor. Input zoom factor can be null, in this case leave the zoom unchanged.
1067      */
animatedMove(PointF topLeft, Float zoom)1068     boolean animatedMove(PointF topLeft, Float zoom) {
1069         RectF moveToRect = getMetrics().getCssViewport();
1070         moveToRect.offsetTo(topLeft.x, topLeft.y);
1071 
1072         ImmutableViewportMetrics finalMetrics = getMetrics();
1073 
1074         finalMetrics = finalMetrics.setViewportOrigin(
1075                 moveToRect.left * finalMetrics.zoomFactor,
1076                 moveToRect.top * finalMetrics.zoomFactor);
1077 
1078         if (zoom != null) {
1079             finalMetrics = finalMetrics.scaleTo(zoom, new PointF(0.0f, 0.0f));
1080         }
1081         finalMetrics = getValidViewportMetrics(finalMetrics);
1082 
1083         bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM);
1084         return true;
1085     }
1086 
1087     /** This function must be called from the UI thread. */
abortPanning()1088     public void abortPanning() {
1089         checkMainThread();
1090         bounce();
1091     }
1092 
setOverScrollMode(int overscrollMode)1093     public void setOverScrollMode(int overscrollMode) {
1094         mX.setOverScrollMode(overscrollMode);
1095         mY.setOverScrollMode(overscrollMode);
1096     }
1097 
getOverScrollMode()1098     public int getOverScrollMode() {
1099         return mX.getOverScrollMode();
1100     }
1101 }
1102