Bug 1508372 - Add scrollTo and scrollBy to PanZoomController r=geckoview-reviewers,snorp,esawin
authorRandall Barker <rbarker@mozilla.com>
Thu, 14 Feb 2019 19:04:06 +0000
changeset 459184 8066fe9c81eeb2a3df48220109a6b4058cd5fd5a
parent 459183 7f128351ac64c04d0d26d56e2e9fb92f79bd4c28
child 459185 e4ca191d62a5560839ca721e66c7620b674a366b
push id35556
push userdvarga@mozilla.com
push dateFri, 15 Feb 2019 01:38:24 +0000
treeherdermozilla-central@b29c87add05f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgeckoview-reviewers, snorp, esawin
bugs1508372
milestone67.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1508372 - Add scrollTo and scrollBy to PanZoomController r=geckoview-reviewers,snorp,esawin The scrollTo() and scrollBy() functions in the PanZoomController may be used to scroll the root document in GeckoView. Differential Revision: https://phabricator.services.mozilla.com/D18898
mobile/android/chrome/geckoview/GeckoViewContentChild.js
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/assets/www/scroll.html
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
mobile/android/modules/geckoview/GeckoViewContent.jsm
--- a/mobile/android/chrome/geckoview/GeckoViewContentChild.js
+++ b/mobile/android/chrome/geckoview/GeckoViewContentChild.js
@@ -2,16 +2,27 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 const {GeckoViewChildModule} = ChromeUtils.import("resource://gre/modules/GeckoViewChildModule.jsm");
 var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
+// This needs to match ScreenLength.java
+const SCREEN_LENGTH_TYPE_PIXEL = 0;
+const SCREEN_LENGTH_TYPE_VIEWPORT_WIDTH = 1;
+const SCREEN_LENGTH_TYPE_VIEWPORT_HEIGHT = 2;
+const SCREEN_LENGTH_DOCUMENT_WIDTH = 3;
+const SCREEN_LENGTH_DOCUMENT_HEIGHT = 4;
+
+// This need to match PanZoomController.java
+const SCROLL_BEHAVIOR_SMOOTH = 0;
+const SCROLL_BEHAVIOR_AUTO = 1;
+
 XPCOMUtils.defineLazyModuleGetters(this, {
   FormLikeFactory: "resource://gre/modules/FormLikeFactory.jsm",
   GeckoViewAutoFill: "resource://gre/modules/GeckoViewAutoFill.jsm",
   PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.jsm",
   SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.jsm",
 });
 
 class GeckoViewContentChild extends GeckoViewChildModule {
@@ -28,16 +39,18 @@ class GeckoViewContentChild extends Geck
                                            this);
     this.messageManager.addMessageListener("GeckoView:DOMFullscreenExited",
                                            this);
     this.messageManager.addMessageListener("GeckoView:RestoreState", this);
     this.messageManager.addMessageListener("GeckoView:SaveState", this);
     this.messageManager.addMessageListener("GeckoView:SetActive", this);
     this.messageManager.addMessageListener("GeckoView:UpdateInitData", this);
     this.messageManager.addMessageListener("GeckoView:ZoomToInput", this);
+    this.messageManager.addMessageListener("GeckoView:ScrollBy", this);
+    this.messageManager.addMessageListener("GeckoView:ScrollTo", this);
 
     const options = {
         mozSystemGroup: true,
         capture: false,
     };
     addEventListener("DOMFormHasPassword", this, options);
     addEventListener("DOMInputPasswordAdded", this, options);
     addEventListener("pagehide", this, options);
@@ -104,16 +117,41 @@ class GeckoViewContentChild extends Geck
 
     scrolldata.zoom.displaySize = displaySize;
 
     formdata = PrivacyFilter.filterFormData(formdata || {});
 
     return {history, formdata, scrolldata};
   }
 
+  toPixels(aLength, aType) {
+    if (aType === SCREEN_LENGTH_TYPE_PIXEL) {
+      return aLength;
+    } else if (aType === SCREEN_LENGTH_TYPE_VIEWPORT_WIDTH) {
+      return aLength * content.innerWidth;
+    } else if (aType === SCREEN_LENGTH_TYPE_VIEWPORT_HEIGHT) {
+      return aLength * content.innerHeight;
+    } else if (aType === SCREEN_LENGTH_DOCUMENT_WIDTH) {
+      return aLength * content.document.body.scrollWidth;
+    } else if (aType === SCREEN_LENGTH_DOCUMENT_HEIGHT) {
+      return aLength * content.document.body.scrollHeight;
+    }
+
+    return aLength;
+  }
+
+  toScrollBehavior(aBehavior) {
+    if (aBehavior === SCROLL_BEHAVIOR_SMOOTH) {
+      return "smooth";
+    } else if (aBehavior === SCROLL_BEHAVIOR_AUTO) {
+      return "auto";
+    }
+    return "smooth";
+  }
+
   receiveMessage(aMsg) {
     debug `receiveMessage: ${aMsg.name}`;
 
     switch (aMsg.name) {
       case "GeckoView:DOMFullscreenEntered":
         if (content) {
           content.windowUtils
                  .handleFullscreenRequests();
@@ -241,16 +279,30 @@ class GeckoViewContentChild extends Geck
           }
         break;
 
       case "GeckoView:UpdateInitData":
         // Provide a hook for native code to detect a transfer.
         Services.obs.notifyObservers(
             docShell, "geckoview-content-global-transferred");
         break;
+      case "GeckoView:ScrollBy":
+        content.scrollBy({
+          top: this.toPixels(aMsg.data.heightValue, aMsg.data.heightType),
+          left: this.toPixels(aMsg.data.widthValue, aMsg.data.widthType),
+          behavior: this.toScrollBehavior(aMsg.data.behavior),
+        });
+        break;
+      case "GeckoView:ScrollTo":
+        content.scrollTo({
+          top: this.toPixels(aMsg.data.heightValue, aMsg.data.heightType),
+          left: this.toPixels(aMsg.data.widthValue, aMsg.data.widthType),
+          behavior: this.toScrollBehavior(aMsg.data.behavior),
+        });
+        break;
     }
   }
 
   // eslint-disable-next-line complexity
   handleEvent(aEvent) {
     debug `handleEvent: ${aEvent.type}`;
 
     switch (aEvent.type) {
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -827,18 +827,26 @@ package org.mozilla.geckoview {
   }
 
   @android.support.annotation.UiThread public class PanZoomController extends org.mozilla.gecko.mozglue.JNIObject {
     ctor protected PanZoomController(org.mozilla.geckoview.GeckoSession);
     method public float getScrollFactor();
     method public boolean onMotionEvent(@android.support.annotation.NonNull android.view.MotionEvent);
     method public boolean onMouseEvent(@android.support.annotation.NonNull android.view.MotionEvent);
     method public boolean onTouchEvent(@android.support.annotation.NonNull android.view.MotionEvent);
+    method @android.support.annotation.UiThread public void scrollBy(@android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength, @android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength);
+    method @android.support.annotation.UiThread public void scrollBy(@android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength, @android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength, int);
+    method @android.support.annotation.UiThread public void scrollTo(@android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength, @android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength);
+    method @android.support.annotation.UiThread public void scrollTo(@android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength, @android.support.annotation.NonNull org.mozilla.geckoview.ScreenLength, int);
+    method @android.support.annotation.UiThread public void scrollToBottom();
+    method @android.support.annotation.UiThread public void scrollToTop();
     method public void setIsLongpressEnabled(boolean);
     method public void setScrollFactor(float);
+    field public static final int SCROLL_BEHAVIOR_AUTO = 1;
+    field public static final int SCROLL_BEHAVIOR_SMOOTH = 0;
   }
 
   public abstract class RuntimeSettings implements android.os.Parcelable {
     ctor protected RuntimeSettings();
     ctor protected RuntimeSettings(@android.support.annotation.Nullable org.mozilla.geckoview.RuntimeSettings);
     method @android.support.annotation.AnyThread public void readFromParcel(@android.support.annotation.NonNull android.os.Parcel);
     method @android.support.annotation.AnyThread protected void updatePrefs(@android.support.annotation.NonNull org.mozilla.geckoview.RuntimeSettings);
   }
@@ -849,16 +857,32 @@ package org.mozilla.geckoview {
     method @android.support.annotation.AnyThread @android.support.annotation.NonNull protected Settings getSettings();
     method @android.support.annotation.AnyThread @android.support.annotation.NonNull protected abstract Settings newSettings(@android.support.annotation.Nullable Settings);
   }
 
   public final class RuntimeTelemetry {
     method @android.support.annotation.AnyThread @android.support.annotation.NonNull public org.mozilla.geckoview.GeckoResult<org.mozilla.gecko.util.GeckoBundle> getSnapshots(boolean);
   }
 
+  public class ScreenLength {
+    method @android.support.annotation.NonNull @android.support.annotation.AnyThread public static org.mozilla.geckoview.ScreenLength bottom();
+    method @android.support.annotation.NonNull @android.support.annotation.AnyThread public static org.mozilla.geckoview.ScreenLength fromPixels(double);
+    method @android.support.annotation.NonNull @android.support.annotation.AnyThread public static org.mozilla.geckoview.ScreenLength fromViewportHeight(double);
+    method @android.support.annotation.NonNull @android.support.annotation.AnyThread public static org.mozilla.geckoview.ScreenLength fromViewportWidth(double);
+    method @android.support.annotation.AnyThread public int getType();
+    method @android.support.annotation.AnyThread public double getValue();
+    method @android.support.annotation.NonNull @android.support.annotation.AnyThread public static org.mozilla.geckoview.ScreenLength top();
+    method @android.support.annotation.NonNull @android.support.annotation.AnyThread public static org.mozilla.geckoview.ScreenLength zero();
+    field public static final int DOCUMENT_HEIGHT = 4;
+    field public static final int DOCUMENT_WIDTH = 3;
+    field public static final int PIXEL = 0;
+    field public static final int VIEWPORT_HEIGHT = 2;
+    field public static final int VIEWPORT_WIDTH = 1;
+  }
+
   @android.support.annotation.UiThread public class SessionAccessibility {
     method @android.support.annotation.Nullable public android.view.View getView();
     method public boolean onMotionEvent(@android.support.annotation.NonNull android.view.MotionEvent);
     method @android.support.annotation.UiThread public void setView(@android.support.annotation.Nullable android.view.View);
   }
 
   @android.support.annotation.AnyThread public final class SessionFinder {
     method public void clear();
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/scroll.html
@@ -0,0 +1,35 @@
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <style type="text/css">
+body {
+   background-color: white;
+   margin: 0;
+}
+
+#one {
+   background-color: red;
+   width: 200vw;
+   height: 100vh;
+}
+
+#two {
+   background-color: green;
+   width: 200vw;
+   height: 100vh;
+}
+
+#three {
+   background-color: blue;
+   width: 200vw;
+   height: 100vh;
+}
+    </style>
+  </head>
+  <body>
+    <div id="one"></div>
+    <div id="two"></div>
+    <div id="three"></div>
+  </body>
+</html>
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
@@ -47,16 +47,17 @@ open class BaseSessionTest(noErrorCollec
         const val VIDEO_WEBM_PATH = "/assets/www/webm.html"
         const val VIDEO_BAD_PATH = "/assets/www/badVideoPath.html"
         const val UNKNOWN_HOST_URI = "http://www.test.invalid/"
         const val FULLSCREEN_PATH = "/assets/www/fullscreen.html"
         const val VIEWPORT_PATH = "/assets/www/viewport.html"
         const val IFRAME_REDIRECT_LOCAL = "/assets/www/iframe_redirect_local.html"
         const val IFRAME_REDIRECT_AUTOMATION = "/assets/www/iframe_redirect_automation.html"
         const val AUTOPLAY_PATH = "/assets/www/autoplay.html"
+        const val SCROLL_TEST_PATH = "/assets/www/scroll.html"
     }
 
     @get:Rule val sessionRule = GeckoSessionTestRule()
 
     @get:Rule val errors = ErrorCollector()
 
     val mainSession get() = sessionRule.session
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt
@@ -0,0 +1,64 @@
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.ScreenLength
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDevToolsAPI
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+import android.support.test.filters.MediumTest
+import android.support.test.runner.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.PanZoomController
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PanZoomControllerTest : BaseSessionTest() {
+    private val errorEpsilon = 3.0
+    @WithDevToolsAPI
+    @WithDisplay(width = 100, height = 100)
+    @Test
+    fun scrollBy() {
+        sessionRule.session.loadTestPath(SCROLL_TEST_PATH)
+        sessionRule.waitForPageStop()
+        val vh = sessionRule.evaluateJS(mainSession, "window.innerHeight") as Double
+        assertThat("Viewport height is not zero", vh, greaterThan(0.0))
+        sessionRule.session.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromViewportHeight(1.0), PanZoomController.SCROLL_BEHAVIOR_AUTO)
+        val scrollY = sessionRule.evaluateJS(mainSession, "window.scrollY") as Double
+        assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon))
+
+        sessionRule.session.loadTestPath(SCROLL_TEST_PATH)
+        sessionRule.waitForPageStop()
+        val vw = sessionRule.evaluateJS(mainSession, "window.innerWidth") as Double
+        assertThat("Viewport width is not zero", vw, greaterThan(0.0))
+        sessionRule.session.panZoomController.scrollBy(ScreenLength.fromViewportWidth(1.0), ScreenLength.zero(), PanZoomController.SCROLL_BEHAVIOR_AUTO)
+        val scrollX = sessionRule.evaluateJS(mainSession, "window.scrollX") as Double
+        assertThat("scrollBy should have scrolled along x axis one viewport", scrollX, closeTo(vw, errorEpsilon))
+    }
+
+    @WithDevToolsAPI
+    @WithDisplay(width = 100, height = 100)
+    @Test
+    fun scrollTo() {
+        sessionRule.session.loadTestPath(SCROLL_TEST_PATH)
+        sessionRule.waitForPageStop()
+        val vh = sessionRule.evaluateJS(mainSession, "window.innerHeight") as Double
+        val scrollHeight = sessionRule.evaluateJS(mainSession, "window.document.body.scrollHeight") as Double
+        assertThat("Viewport height is not zero", vh, greaterThan(0.0))
+        assertThat("scrollHeight height is not zero", scrollHeight, greaterThan(0.0))
+        sessionRule.session.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.bottom(), PanZoomController.SCROLL_BEHAVIOR_AUTO)
+        var scrollY = sessionRule.evaluateJS(mainSession, "window.scrollY") as Double
+        assertThat("scrollTo should have scrolled to bottom", scrollY, closeTo(scrollHeight - vh, 3.0))
+
+        sessionRule.session.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.top(), PanZoomController.SCROLL_BEHAVIOR_AUTO)
+        scrollY = sessionRule.evaluateJS(mainSession, "window.scrollY") as Double
+        assertThat("scrollTo should have scrolled to top", scrollY, closeTo(0.0, errorEpsilon))
+
+        val vw = sessionRule.evaluateJS(mainSession, "window.innerWidth") as Double
+        val scrollWidth = sessionRule.evaluateJS(mainSession, "window.document.body.scrollWidth") as Double
+
+        sessionRule.session.panZoomController.scrollTo(ScreenLength.fromViewportWidth(1.0), ScreenLength.zero(), PanZoomController.SCROLL_BEHAVIOR_AUTO)
+        val scrollX = sessionRule.evaluateJS(mainSession, "window.scrollX") as Double
+        assertThat("scrollTo should have scrolled to right", scrollX, closeTo(scrollWidth - vw, errorEpsilon))
+    }
+}
\ No newline at end of file
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
@@ -2,42 +2,60 @@
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.geckoview;
 
 import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.graphics.Rect;
 import android.os.SystemClock;
 import android.support.annotation.NonNull;
 import android.support.annotation.UiThread;
+import android.support.annotation.IntDef;
 import android.util.Log;
 import android.util.Pair;
 import android.view.MotionEvent;
 import android.view.InputDevice;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
 import java.util.ArrayList;
 
 @UiThread
 public class PanZoomController extends JNIObject {
     private static final String LOGTAG = "GeckoNPZC";
     private static final int EVENT_SOURCE_SCROLL = 0;
     private static final int EVENT_SOURCE_MOTION = 1;
     private static final int EVENT_SOURCE_MOUSE = 2;
 
     private final GeckoSession mSession;
     private final Rect mTempRect = new Rect();
     private boolean mAttached;
     private float mPointerScrollFactor = 64.0f;
     private long mLastDownTime;
 
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({SCROLL_BEHAVIOR_SMOOTH, SCROLL_BEHAVIOR_AUTO})
+    /* package */ @interface ScrollBehaviorType {}
+
+    /**
+     * Specifies smooth scrolling which animates content to the desired scroll position.
+     */
+    public static final int SCROLL_BEHAVIOR_SMOOTH = 0;
+    /**
+     * Specifies auto scrolling which jumps content to the desired scroll position.
+     */
+    public static final int SCROLL_BEHAVIOR_AUTO = 1;
+
     private SynthesizedEventState mPointerState;
 
     private ArrayList<Pair<Integer, MotionEvent>> mQueuedEvents;
 
     @WrapForJNI(calledFrom = "ui")
     private native boolean handleMotionEvent(
             int action, int actionIndex, long time, int metaState,  float screenX, float screenY,
             int pointerId[], float x[], float y[], float orientation[], float pressure[],
@@ -525,9 +543,89 @@ public class PanZoomController extends J
     }
 
     @WrapForJNI(calledFrom = "ui")
     private void synthesizeNativeMouseEvent(int eventType, int clientX, int clientY) {
         synthesizeNativePointer(InputDevice.SOURCE_MOUSE,
                                 PointerInfo.RESERVED_MOUSE_POINTER_ID,
                                 eventType, clientX, clientY, 0, 0);
     }
+
+    /**
+     * Scroll the document body by an offset from the current scroll position.
+     * Uses {@link #SCROLL_BEHAVIOR_SMOOTH}.
+     *
+     * @param width {@link ScreenLength} offset to scroll along X axis.
+     * @param height {@link ScreenLength} offset to scroll along Y axis.
+     */
+    @UiThread
+    public void scrollBy(@NonNull ScreenLength width, @NonNull ScreenLength height) {
+        scrollBy(width, height, SCROLL_BEHAVIOR_SMOOTH);
+    }
+
+    /**
+     * Scroll the document body by an offset from the current scroll position.
+     *
+     * @param width {@link ScreenLength} offset to scroll along X axis.
+     * @param height {@link ScreenLength} offset to scroll along Y axis.
+     * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link #SCROLL_BEHAVIOR_AUTO},
+     *                 that specifies how to scroll the content.
+     */
+    @UiThread
+    public void scrollBy(@NonNull ScreenLength width, @NonNull ScreenLength height, @ScrollBehaviorType int behavior) {
+        final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+        mSession.getEventDispatcher().dispatch("GeckoView:ScrollBy", msg);
+    }
+
+    /**
+     * Scroll the document body to an absolute  position.
+     * Uses {@link #SCROLL_BEHAVIOR_SMOOTH}.
+     *
+     * @param width {@link ScreenLength} position to scroll along X axis.
+     * @param height {@link ScreenLength} position to scroll along Y axis.
+     */
+    @UiThread
+    public void scrollTo(@NonNull ScreenLength width, @NonNull ScreenLength height) {
+        scrollTo(width, height, SCROLL_BEHAVIOR_SMOOTH);
+    }
+
+    /**
+     * Scroll the document body to an absolute position.
+     *
+     * @param width {@link ScreenLength} position to scroll along X axis.
+     * @param height {@link ScreenLength} position to scroll along Y axis.
+     * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link #SCROLL_BEHAVIOR_AUTO},
+     *                 that specifies how to scroll the content.
+     */
+    @UiThread
+    public void scrollTo(@NonNull ScreenLength width, @NonNull ScreenLength height, @ScrollBehaviorType int behavior) {
+        final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+        mSession.getEventDispatcher().dispatch("GeckoView:ScrollTo", msg);
+    }
+
+    /**
+     * Scroll to the top left corner of the screen.
+     * Uses {@link #SCROLL_BEHAVIOR_SMOOTH}.
+     */
+    @UiThread
+    public void scrollToTop() {
+        scrollTo(ScreenLength.zero(), ScreenLength.top(), SCROLL_BEHAVIOR_SMOOTH);
+    }
+
+    /**
+     * Scroll to the bottom left corner of the screen.
+     * Uses {@link #SCROLL_BEHAVIOR_SMOOTH}.
+     */
+    @UiThread
+    public void scrollToBottom() {
+        scrollTo(ScreenLength.zero(), ScreenLength.bottom(), SCROLL_BEHAVIOR_SMOOTH);
+    }
+
+    private GeckoBundle buildScrollMessage(@NonNull ScreenLength width, @NonNull ScreenLength height, @ScrollBehaviorType int behavior) {
+        final GeckoBundle msg = new GeckoBundle();
+        msg.putDouble("widthValue", width.getValue());
+        msg.putInt("widthType", width.getType());
+        msg.putDouble("heightValue", height.getValue());
+        msg.putInt("heightType", height.getType());
+        msg.putInt("behavior", behavior);
+        return msg;
+    }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
@@ -0,0 +1,156 @@
+/* License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.support.annotation.AnyThread;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * ScreenLength is a class that represents a length on the screen using different units.
+ * The default unit is a pixel. However lengths may be also represented by a dimension
+ * of the visual viewport or of the full scroll size of the root document.
+ */
+public class ScreenLength {
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({PIXEL, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, DOCUMENT_WIDTH, DOCUMENT_HEIGHT})
+    /* package */ @interface ScreenLengthType {}
+
+    /**
+     * Pixel units.
+     */
+    public static final int PIXEL = 0;
+    /**
+     * Units are in visual viewport width. If the visual viewport is 100 pixels wide, then a value
+     * of 2.0 would represent a length of 200 pixels.
+     * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual Viewport</a>
+     */
+    public static final int VIEWPORT_WIDTH = 1;
+    /**
+     * Units are in visual viewport height. If the visual viewport is 100 pixels high, then a value
+     * of 2.0 would represent a length of 200 pixels.
+     * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual Viewport</a>
+     */
+    public static final int VIEWPORT_HEIGHT = 2;
+    /**
+     * Units represent the entire scrollable documents width. If the document is 1000 pixels wide
+     * then a value of 1.0 would represent 1000 pixels.
+     */
+    public static final int DOCUMENT_WIDTH = 3;
+    /**
+     * Units represent the entire scrollable documents height. If the document is 1000 pixels tall
+     * then a value of 1.0 would represent 1000 pixels.
+     */
+    public static final int DOCUMENT_HEIGHT = 4;
+
+    /**
+     * Create a ScreenLength of zero pixels length.
+     * Type is {@link #PIXEL}.
+     * @return ScreenLength of zero length.
+     */
+    @NonNull
+    @AnyThread
+    public static ScreenLength zero() {
+        return new ScreenLength(0.0, PIXEL);
+    }
+
+    /**
+     * Create a ScreenLength of zero pixels length.
+     * Type is {@link #PIXEL}.  Can be used to scroll to the top of a page when used with
+     * PanZoomController.scrollTo()
+     * @return ScreenLength of zero length.
+     */
+    @NonNull
+    @AnyThread
+    public static ScreenLength top() {
+        return zero();
+    }
+
+    /**
+     * Create a ScreenLength of the documents height.
+     * Type is {@link #DOCUMENT_HEIGHT}. Can be used to scroll to the bottom of a page when used with
+     * {@link PanZoomController#scrollTo(ScreenLength, ScreenLength)}
+     * @return ScreenLength of document height.
+     */
+    @NonNull
+    @AnyThread
+    public static ScreenLength bottom() {
+        return new ScreenLength(1.0, DOCUMENT_HEIGHT);
+    }
+
+    /**
+     * Create a ScreenLength of a specific length.
+     * Type is {@link #PIXEL}.
+     * @param value Pixel length.
+     * @return ScreenLength of document height.
+     */
+    @NonNull
+    @AnyThread
+    public static ScreenLength fromPixels(final double value) {
+        return new ScreenLength(value, PIXEL);
+    }
+
+    /**
+     * Create a ScreenLength that uses the visual viewport width as units.
+     * Type is {@link #VIEWPORT_WIDTH}. Can be used with {@link PanZoomController#scrollBy(ScreenLength, ScreenLength)}
+     * to scroll a value of the width of visual viewport content.
+     * @param value Factor used to calculate length. A value of 2.0 would indicate a length
+     *              twice as long as the length of the visual viewports width.
+     * @return ScreenLength of specifying a length of value * visual viewport width.
+     */
+    @NonNull
+    @AnyThread
+    public static ScreenLength fromViewportWidth(final double value) {
+        return new ScreenLength(value, VIEWPORT_WIDTH);
+    }
+
+    /**
+     * Create a ScreenLength that uses the visual viewport width as units.
+     * Type is {@link #VIEWPORT_HEIGHT}. Can be used with {@link PanZoomController#scrollBy(ScreenLength, ScreenLength)}
+     * to scroll a value of the height of visual viewport content.
+     * @param value Factor used to calculate length. A value of 2.0 would indicate a length
+     *              twice as long as the length of the visual viewports height.
+     * @return ScreenLength of specifying a length of value * visual viewport width.
+     */
+    @NonNull
+    @AnyThread
+    public static ScreenLength fromViewportHeight(final double value) {
+        return new ScreenLength(value, VIEWPORT_HEIGHT);
+    }
+
+    private final double mValue;
+    @ScreenLengthType private final int mType;
+
+    /* package */ ScreenLength(final double value, @ScreenLengthType final int type) {
+        mValue = value;
+        mType = type;
+    }
+
+    /**
+     * Returns the scalar value used to calculate length.
+     * The units of the returned valued are defined by what is returned by
+     * {@link #getType()}
+     * @return Scalar value of the length.
+     */
+    @AnyThread
+    public double getValue() {
+        return mValue;
+    }
+
+    /**
+     * Returns the unit type of the length
+     * The length can be one of the following:
+     * {@link #PIXEL}, {@link #VIEWPORT_WIDTH}, {@link #VIEWPORT_HEIGHT}, {@link #DOCUMENT_WIDTH}, {@link #DOCUMENT_HEIGHT}
+     * @return Unit type of the length.
+     */
+
+    @AnyThread
+    @ScreenLengthType
+    public int getType() {
+        return mType;
+    }
+}
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -18,16 +18,18 @@ exclude: true
 
 - Added nested `ContentBlocking` runtime settings.
 
 - Added `RuntimeSettings` base class to support nested settings.
 
 - Added `baseUri` to [`ContentDelegate.ContextElement`][65.21] and changed
   `linkUri` to absolute form.
 
+- Added `scrollBy()` and `scrollTo()` to `PanZoomController`.
+
 ## v66
 - Removed redundant field `GeckoSession.ProgressDelegate.SecurityInformation.trackingMode`.
   Use `GeckoSession.TrackingProtectionDelegate.onTrackerBlocked` for
   notification of blocked elements during page load.
 
 - Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all APIs.
 
 [66.1]: https://developer.android.com/reference/android/support/annotation/NonNull
@@ -127,9 +129,9 @@ exclude: true
 [65.23]: ../GeckoSession.FinderResult.html
 
 - Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a
   [`GeckoResult<String>`][65.25].
 
 [65.24]: ../CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
 [65.25]: ../GeckoResult.html
 
-[api-version]: a42a6f4481dd690ac46f14d8e692785bb00e8b04
+[api-version]: fb3375bbe85695b337d830dcf3fffec7231f4e4b
--- a/mobile/android/modules/geckoview/GeckoViewContent.jsm
+++ b/mobile/android/modules/geckoview/GeckoViewContent.jsm
@@ -20,16 +20,18 @@ class GeckoViewContent extends GeckoView
         "GeckoView:ClearMatches",
         "GeckoView:DisplayMatches",
         "GeckoView:FindInPage",
         "GeckoView:RestoreState",
         "GeckoView:SaveState",
         "GeckoView:SetActive",
         "GeckoView:SetFocused",
         "GeckoView:ZoomToInput",
+        "GeckoView:ScrollBy",
+        "GeckoView:ScrollTo",
     ]);
 
     this.messageManager.addMessageListener("GeckoView:SaveStateFinish", this);
   }
 
   onEnable() {
     this.window.addEventListener("MozDOMFullscreen:Entered", this,
                                  /* capture */ true, /* untrusted */ false);
@@ -72,16 +74,22 @@ class GeckoViewContent extends GeckoView
       }
       case "GeckoView:FindInPage": {
         this._findInPage(aData, aCallback);
         break;
       }
       case "GeckoView:ZoomToInput":
         this.messageManager.sendAsyncMessage(aEvent);
         break;
+      case "GeckoView:ScrollBy":
+        this.messageManager.sendAsyncMessage(aEvent, aData);
+        break;
+      case "GeckoView:ScrollTo":
+        this.messageManager.sendAsyncMessage(aEvent, aData);
+        break;
       case "GeckoView:SetActive":
         if (aData.active) {
           this.browser.docShellIsActive = true;
         } else {
           this.browser.docShellIsActive = false;
         }
         var msgData = {
           active: aData.active,