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