summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java')
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java605
1 files changed, 605 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java
new file mode 100644
index 000000000..e299b5744
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java
@@ -0,0 +1,605 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.gecko.gfx;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.util.FloatUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.graphics.PointF;
+import android.support.v4.view.ViewCompat;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.animation.LinearInterpolator;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+public class DynamicToolbarAnimator {
+ private static final String LOGTAG = "GeckoDynamicToolbarAnimator";
+ private static final String PREF_SCROLL_TOOLBAR_THRESHOLD = "browser.ui.scroll-toolbar-threshold";
+
+ public static enum PinReason {
+ RELAYOUT,
+ ACTION_MODE,
+ FULL_SCREEN,
+ CARET_DRAG
+ }
+
+ private final Set<PinReason> pinFlags = Collections.synchronizedSet(EnumSet.noneOf(PinReason.class));
+
+ // The duration of the animation in ns
+ private static final long ANIMATION_DURATION = 150000000;
+
+ private final GeckoLayerClient mTarget;
+ private final List<LayerView.DynamicToolbarListener> mListeners;
+
+ /* The translation to be applied to the toolbar UI view. This is the
+ * distance from the default/initial location (at the top of the screen,
+ * visible to the user) to where we want it to be. This variable should
+ * always be between 0 (toolbar fully visible) and the height of the toolbar
+ * (toolbar fully hidden), inclusive.
+ */
+ private float mToolbarTranslation;
+
+ /* The translation to be applied to the LayerView. This is the distance from
+ * the default/initial location (just below the toolbar, with the bottom
+ * extending past the bottom of the screen) to where we want it to be.
+ * This variable should always be between 0 and the height of the toolbar,
+ * inclusive.
+ */
+ private float mLayerViewTranslation;
+
+ /* This stores the maximum translation that can be applied to the toolbar
+ * and layerview when scrolling. This is populated with the height of the
+ * toolbar. */
+ private float mMaxTranslation;
+
+ /* This interpolator is used for the above mentioned animation */
+ private LinearInterpolator mInterpolator;
+
+ /* This is the proportion of the viewport rect that needs to be travelled
+ * while scrolling before the translation will start taking effect.
+ */
+ private float SCROLL_TOOLBAR_THRESHOLD = 0.20f;
+ /* The ID of the prefs listener for the scroll-toolbar threshold */
+ private final PrefsHelper.PrefHandler mPrefObserver;
+
+ /* While we are resizing the viewport to account for the toolbar, the Java
+ * code and painted layer metrics in the compositor have different notions
+ * of the CSS viewport height. The Java value is stored in the
+ * GeckoLayerClient's viewport metrics, and the Gecko one is stored here.
+ * This allows us to adjust fixed-pos items correctly.
+ * You must synchronize on mTarget.getLock() to read/write this. */
+ private Integer mHeightDuringResize;
+
+ /* This tracks if we should trigger a "snap" on the next composite. A "snap"
+ * is when we simultaneously move the LayerView and change the scroll offset
+ * in the compositor so that everything looks the same on the screen but
+ * has really been shifted.
+ * You must synchronize on |this| to read/write this. */
+ private boolean mSnapRequired = false;
+
+ /* The task that handles showing/hiding toolbar */
+ private DynamicToolbarAnimationTask mAnimationTask;
+
+ /* The start point of a drag, used for scroll-based dynamic toolbar
+ * behaviour. */
+ private PointF mTouchStart;
+ private float mLastTouch;
+
+ /* Set to true when root content is being scrolled */
+ private boolean mScrollingRootContent;
+
+ public DynamicToolbarAnimator(GeckoLayerClient aTarget) {
+ mTarget = aTarget;
+ mListeners = new ArrayList<LayerView.DynamicToolbarListener>();
+
+ mInterpolator = new LinearInterpolator();
+
+ // Listen to the dynamic toolbar pref
+ mPrefObserver = new PrefsHelper.PrefHandlerBase() {
+ @Override
+ public void prefValue(String pref, int value) {
+ SCROLL_TOOLBAR_THRESHOLD = value / 100.0f;
+ }
+ };
+ PrefsHelper.addObserver(new String[] { PREF_SCROLL_TOOLBAR_THRESHOLD }, mPrefObserver);
+ }
+
+ public void destroy() {
+ PrefsHelper.removeObserver(mPrefObserver);
+ }
+
+ public void addTranslationListener(LayerView.DynamicToolbarListener aListener) {
+ mListeners.add(aListener);
+ }
+
+ public void removeTranslationListener(LayerView.DynamicToolbarListener aListener) {
+ mListeners.remove(aListener);
+ }
+
+ private void fireListeners() {
+ for (LayerView.DynamicToolbarListener listener : mListeners) {
+ listener.onTranslationChanged(mToolbarTranslation, mLayerViewTranslation);
+ }
+ }
+
+ void onPanZoomStopped() {
+ for (LayerView.DynamicToolbarListener listener : mListeners) {
+ listener.onPanZoomStopped();
+ }
+ }
+
+ void onMetricsChanged(ImmutableViewportMetrics aMetrics) {
+ for (LayerView.DynamicToolbarListener listener : mListeners) {
+ listener.onMetricsChanged(aMetrics);
+ }
+ }
+
+ public void setMaxTranslation(float maxTranslation) {
+ ThreadUtils.assertOnUiThread();
+ if (maxTranslation < 0) {
+ Log.e(LOGTAG, "Got a negative max-translation value: " + maxTranslation + "; clamping to zero");
+ mMaxTranslation = 0;
+ } else {
+ mMaxTranslation = maxTranslation;
+ }
+ }
+
+ public float getMaxTranslation() {
+ return mMaxTranslation;
+ }
+
+ public float getToolbarTranslation() {
+ return mToolbarTranslation;
+ }
+
+ /**
+ * If true, scroll changes will not affect translation.
+ */
+ public boolean isPinned() {
+ return !pinFlags.isEmpty();
+ }
+
+ public boolean isPinnedBy(PinReason reason) {
+ return pinFlags.contains(reason);
+ }
+
+ public void setPinned(boolean pinned, PinReason reason) {
+ if (pinned) {
+ pinFlags.add(reason);
+ } else {
+ pinFlags.remove(reason);
+ }
+ }
+
+ public void showToolbar(boolean immediately) {
+ animateToolbar(true, immediately);
+ }
+
+ public void hideToolbar(boolean immediately) {
+ animateToolbar(false, immediately);
+ }
+
+ public void setScrollingRootContent(boolean isRootContent) {
+ mScrollingRootContent = isRootContent;
+ }
+
+ private void animateToolbar(final boolean showToolbar, boolean immediately) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAnimationTask != null) {
+ mTarget.getView().removeRenderTask(mAnimationTask);
+ mAnimationTask = null;
+ }
+
+ float desiredTranslation = (showToolbar ? 0 : mMaxTranslation);
+ Log.v(LOGTAG, "Requested " + (immediately ? "immediate " : "") + "toolbar animation to translation " + desiredTranslation);
+ if (FloatUtils.fuzzyEquals(mToolbarTranslation, desiredTranslation)) {
+ // If we're already pretty much in the desired position, don't bother
+ // with a full animation; do an immediate jump
+ immediately = true;
+ Log.v(LOGTAG, "Changing animation to immediate jump");
+ }
+
+ if (showToolbar && immediately) {
+ // Special case for showing the toolbar immediately: some of the call
+ // sites expect this to happen synchronously, so let's do that. This
+ // is safe because if we are showing the toolbar from a hidden state
+ // there is no chance of showing garbage
+ mToolbarTranslation = desiredTranslation;
+ fireListeners();
+ // And then proceed with the normal flow (some of which will be
+ // a no-op now)...
+ }
+
+ if (!showToolbar) {
+ // If we are hiding the toolbar, we need to move the LayerView first,
+ // so that we don't end up showing garbage under the toolbar when
+ // it is hidden. In the case that we are showing the toolbar, we
+ // move the LayerView after the toolbar is shown - the
+ // DynamicToolbarAnimationTask calls that upon completion.
+ shiftLayerView(desiredTranslation);
+ }
+
+ mAnimationTask = new DynamicToolbarAnimationTask(desiredTranslation, immediately, showToolbar);
+ mTarget.getView().postRenderTask(mAnimationTask);
+ }
+
+ private synchronized void shiftLayerView(float desiredTranslation) {
+ float layerViewTranslationNeeded = desiredTranslation - mLayerViewTranslation;
+ mLayerViewTranslation = desiredTranslation;
+ synchronized (mTarget.getLock()) {
+ if (layerViewTranslationNeeded == 0 && isResizing()) {
+ // We're already in the middle of a snap, so this new call is
+ // redundant as it's snapping to the same place. Ignore it.
+ return;
+ }
+ mHeightDuringResize = new Integer(mTarget.getViewportMetrics().viewportRectHeight);
+ mSnapRequired = mTarget.setViewportSize(
+ mTarget.getView().getWidth(),
+ mTarget.getView().getHeight() - Math.round(mMaxTranslation - mLayerViewTranslation),
+ new PointF(0, -layerViewTranslationNeeded));
+ if (!mSnapRequired) {
+ mHeightDuringResize = null;
+ ThreadUtils.postToUiThread(new Runnable() {
+ // Post to run it outside of the synchronize blocks. The
+ // delay shouldn't hurt.
+ @Override
+ public void run() {
+ fireListeners();
+ }
+ });
+ }
+ // Request a composite, which will trigger the snap.
+ mTarget.getView().requestRender();
+ }
+ }
+
+ IntSize getViewportSize() {
+ ThreadUtils.assertOnUiThread();
+
+ int viewWidth = mTarget.getView().getWidth();
+ int viewHeight = mTarget.getView().getHeight();
+ float toolbarTranslation = mToolbarTranslation;
+ if (mAnimationTask != null) {
+ // If we have an animation going, mToolbarTranslation may be in flux
+ // and we should use the final value it will settle on.
+ toolbarTranslation = mAnimationTask.getFinalToolbarTranslation();
+ }
+ int viewHeightVisible = viewHeight - Math.round(mMaxTranslation - toolbarTranslation);
+ return new IntSize(viewWidth, viewHeightVisible);
+ }
+
+ boolean isResizing() {
+ return mHeightDuringResize != null;
+ }
+
+ private final Runnable mSnapRunnable = new Runnable() {
+ private int mFrame = 0;
+
+ @Override
+ public final void run() {
+ // It takes 2 frames for the view translation to take effect, at
+ // least on a Nexus 4 device running Android 4.2.2. So we wait for
+ // two frames before doing the notifyAll(), otherwise we get a
+ // short user-visible glitch.
+ // TODO: find a better way to do this, if possible.
+ if (mFrame == 1) {
+ synchronized (this) {
+ this.notifyAll();
+ }
+ mFrame = 0;
+ return;
+ }
+
+ if (mFrame == 0) {
+ fireListeners();
+ }
+
+ ViewCompat.postOnAnimation(mTarget.getView(), this);
+ mFrame++;
+ }
+ };
+
+ void scrollChangeResizeCompleted() {
+ synchronized (mTarget.getLock()) {
+ Log.v(LOGTAG, "Scrollchange resize completed");
+ mHeightDuringResize = null;
+ }
+ }
+
+ /**
+ * "Shrinks" the absolute value of aValue by moving it closer to zero by
+ * aShrinkAmount, but prevents it from crossing over zero. If aShrinkAmount
+ * is negative it is ignored.
+ * @return The shrunken value.
+ */
+ private static float shrinkAbs(float aValue, float aShrinkAmount) {
+ if (aShrinkAmount <= 0) {
+ return aValue;
+ }
+ float shrinkBy = Math.min(Math.abs(aValue), aShrinkAmount);
+ return (aValue < 0 ? aValue + shrinkBy : aValue - shrinkBy);
+ }
+
+ /**
+ * This function takes in a scroll amount and decides how much of that
+ * should be used up to translate things on screen because of the dynamic
+ * toolbar behaviour. It returns the maximum amount that could be used
+ * for translation purposes; the rest must be used for scrolling.
+ */
+ private float decideTranslation(float aDelta,
+ ImmutableViewportMetrics aMetrics,
+ float aTouchTravelDistance) {
+
+ float exposeThreshold = aMetrics.getHeight() * SCROLL_TOOLBAR_THRESHOLD;
+ float translation = aDelta;
+
+ if (translation < 0) { // finger moving upwards
+ translation = shrinkAbs(translation, aMetrics.getOverscroll().top);
+
+ // If the toolbar is in a state between fully hidden and fully shown
+ // (i.e. the user is actively translating it), then we want the
+ // translation to take effect right away. Or if the user has moved
+ // their finger past the required threshold (and is not trying to
+ // scroll past the bottom of the page) then also we want the touch
+ // to cause translation. If the toolbar is fully visible, we only
+ // want the toolbar to hide if the user is scrolling the root content.
+ boolean inBetween = (mToolbarTranslation != 0 && mToolbarTranslation != mMaxTranslation);
+ boolean reachedThreshold = -aTouchTravelDistance >= exposeThreshold;
+ boolean atBottomOfPage = aMetrics.viewportRectBottom() >= aMetrics.pageRectBottom;
+ if (inBetween || (mScrollingRootContent && reachedThreshold && !atBottomOfPage)) {
+ return translation;
+ }
+ } else { // finger moving downwards
+ translation = shrinkAbs(translation, aMetrics.getOverscroll().bottom);
+
+ // Ditto above comment, but in this case if they reached the top and
+ // the toolbar is not shown, then we do want to allow translation
+ // right away.
+ boolean inBetween = (mToolbarTranslation != 0 && mToolbarTranslation != mMaxTranslation);
+ boolean reachedThreshold = aTouchTravelDistance >= exposeThreshold;
+ boolean atTopOfPage = aMetrics.viewportRectTop <= aMetrics.pageRectTop;
+ boolean isToolbarTranslated = (mToolbarTranslation != 0);
+ if (inBetween || reachedThreshold || (atTopOfPage && isToolbarTranslated)) {
+ return translation;
+ }
+ }
+
+ return 0;
+ }
+
+ // Timestamp of the start of the touch event used to calculate toolbar velocity
+ private long mLastEventTime;
+ // Current velocity of the toolbar. Used to populate the velocity queue in C++APZ.
+ private float mVelocity;
+
+ boolean onInterceptTouchEvent(MotionEvent event) {
+ if (isPinned()) {
+ return false;
+ }
+
+ // Animations should never co-exist with the user touching the screen.
+ if (mAnimationTask != null) {
+ mTarget.getView().removeRenderTask(mAnimationTask);
+ mAnimationTask = null;
+ }
+
+ // we only care about single-finger drags here; any other kind of event
+ // should reset and cause us to start over.
+ if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE ||
+ event.getActionMasked() != MotionEvent.ACTION_MOVE ||
+ event.getPointerCount() != 1)
+ {
+ if (mTouchStart != null) {
+ Log.v(LOGTAG, "Resetting touch sequence due to non-move");
+ mTouchStart = null;
+ mVelocity = 0.0f;
+ }
+
+ if (event.getActionMasked() == MotionEvent.ACTION_UP) {
+ // We need to do this even if the toolbar is already fully
+ // visible or fully hidden, because this is what triggers the
+ // viewport resize in content and updates the viewport metrics.
+ boolean toolbarMostlyVisible = mToolbarTranslation < (mMaxTranslation / 2);
+ Log.v(LOGTAG, "All fingers lifted, completing " + (toolbarMostlyVisible ? "show" : "hide"));
+ animateToolbar(toolbarMostlyVisible, false);
+ }
+ return false;
+ }
+
+ if (mTouchStart != null) {
+ float prevDir = mLastTouch - mTouchStart.y;
+ float newDir = event.getRawY() - mLastTouch;
+ if (prevDir != 0 && newDir != 0 && ((prevDir < 0) != (newDir < 0))) {
+ // If the direction of movement changed, reset the travel
+ // distance properties.
+ mTouchStart = null;
+ mVelocity = 0.0f;
+ }
+ }
+
+ if (mTouchStart == null) {
+ mTouchStart = new PointF(event.getRawX(), event.getRawY());
+ mLastTouch = event.getRawY();
+ mLastEventTime = event.getEventTime();
+ return false;
+ }
+
+ float deltaY = event.getRawY() - mLastTouch;
+ long currentTime = event.getEventTime();
+ float deltaTime = (float)(currentTime - mLastEventTime);
+ mLastEventTime = currentTime;
+ if (deltaTime > 0.0f) {
+ mVelocity = -deltaY / deltaTime;
+ } else {
+ mVelocity = 0.0f;
+ }
+ mLastTouch = event.getRawY();
+ float travelDistance = event.getRawY() - mTouchStart.y;
+
+ ImmutableViewportMetrics metrics = mTarget.getViewportMetrics();
+
+ if (metrics.getPageHeight() <= mTarget.getView().getHeight() &&
+ mToolbarTranslation == 0) {
+ // If the page is short and the toolbar is already visible, don't
+ // allow translating it out of view.
+ return false;
+ }
+
+ float translation = decideTranslation(deltaY, metrics, travelDistance);
+
+ float oldToolbarTranslation = mToolbarTranslation;
+ float oldLayerViewTranslation = mLayerViewTranslation;
+ mToolbarTranslation = FloatUtils.clamp(mToolbarTranslation - translation, 0, mMaxTranslation);
+ mLayerViewTranslation = FloatUtils.clamp(mLayerViewTranslation - translation, 0, mMaxTranslation);
+
+ if (oldToolbarTranslation == mToolbarTranslation &&
+ oldLayerViewTranslation == mLayerViewTranslation) {
+ return false;
+ }
+
+ if (mToolbarTranslation == mMaxTranslation) {
+ Log.v(LOGTAG, "Toolbar at maximum translation, calling shiftLayerView(" + mMaxTranslation + ")");
+ shiftLayerView(mMaxTranslation);
+ } else if (mToolbarTranslation == 0) {
+ Log.v(LOGTAG, "Toolbar at minimum translation, calling shiftLayerView(0)");
+ shiftLayerView(0);
+ }
+
+ fireListeners();
+ mTarget.getView().requestRender();
+ return true;
+ }
+
+ // Get the current velocity of the toolbar.
+ float getVelocity() {
+ return mVelocity;
+ }
+
+ public PointF getVisibleEndOfLayerView() {
+ return new PointF(mTarget.getView().getWidth(),
+ mTarget.getView().getHeight() - mMaxTranslation + mLayerViewTranslation);
+ }
+
+ private float bottomOfCssViewport(ImmutableViewportMetrics aMetrics) {
+ return (isResizing() ? mHeightDuringResize : aMetrics.getHeight())
+ + mMaxTranslation - mLayerViewTranslation;
+ }
+
+ private synchronized boolean getAndClearSnapRequired() {
+ boolean snapRequired = mSnapRequired;
+ mSnapRequired = false;
+ return snapRequired;
+ }
+
+ void populateViewTransform(ViewTransform aTransform, ImmutableViewportMetrics aMetrics) {
+ if (getAndClearSnapRequired()) {
+ synchronized (mSnapRunnable) {
+ ViewCompat.postOnAnimation(mTarget.getView(), mSnapRunnable);
+ try {
+ // hold the in-progress composite until the views have been
+ // translated because otherwise there is a visible glitch.
+ // don't hold for more than 100ms just in case.
+ mSnapRunnable.wait(100);
+ } catch (InterruptedException ie) {
+ }
+ }
+ }
+
+ aTransform.x = aMetrics.viewportRectLeft;
+ aTransform.y = aMetrics.viewportRectTop;
+ aTransform.width = aMetrics.viewportRectWidth;
+ aTransform.height = aMetrics.viewportRectHeight;
+ aTransform.scale = aMetrics.zoomFactor;
+
+ aTransform.fixedLayerMarginTop = mLayerViewTranslation - mToolbarTranslation;
+ float bottomOfScreen = mTarget.getView().getHeight();
+ // We want to move a fixed item from "bottomOfCssViewport" to
+ // "bottomOfScreen". But also the bottom margin > 0 means that bottom
+ // fixed-pos items will move upwards.
+ aTransform.fixedLayerMarginBottom = bottomOfCssViewport(aMetrics) - bottomOfScreen;
+ //Log.v(LOGTAG, "ViewTransform is x=" + aTransform.x + " y=" + aTransform.y
+ // + " z=" + aTransform.scale + " t=" + aTransform.fixedLayerMarginTop
+ // + " b=" + aTransform.fixedLayerMarginBottom);
+ }
+
+ class DynamicToolbarAnimationTask extends RenderTask {
+ private final float mStartTranslation;
+ private final float mEndTranslation;
+ private final boolean mImmediate;
+ private final boolean mShiftLayerView;
+ private boolean mContinueAnimation;
+
+ public DynamicToolbarAnimationTask(float aTranslation, boolean aImmediate, boolean aShiftLayerView) {
+ super(false);
+ mContinueAnimation = true;
+ mStartTranslation = mToolbarTranslation;
+ mEndTranslation = aTranslation;
+ mImmediate = aImmediate;
+ mShiftLayerView = aShiftLayerView;
+ }
+
+ float getFinalToolbarTranslation() {
+ return mEndTranslation;
+ }
+
+ @Override
+ public boolean internalRun(long timeDelta, long currentFrameStartTime) {
+ if (!mContinueAnimation) {
+ return false;
+ }
+
+ // Calculate the progress (between 0 and 1)
+ final float progress = mImmediate
+ ? 1.0f
+ : mInterpolator.getInterpolation(
+ Math.min(1.0f, (System.nanoTime() - getStartTime())
+ / (float)ANIMATION_DURATION));
+
+ // This runs on the compositor thread, so we need to post the
+ // actual work to the UI thread.
+ ThreadUtils.assertNotOnUiThread();
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Move the toolbar as per the animation
+ mToolbarTranslation = FloatUtils.interpolate(mStartTranslation, mEndTranslation, progress);
+ fireListeners();
+
+ if (mShiftLayerView && progress >= 1.0f) {
+ shiftLayerView(mEndTranslation);
+ }
+ }
+ });
+
+ mTarget.getView().requestRender();
+ if (progress >= 1.0f) {
+ mContinueAnimation = false;
+ }
+ return mContinueAnimation;
+ }
+ }
+
+ class SnapMetrics {
+ public final int viewportWidth;
+ public final int viewportHeight;
+ public final float scrollChangeY;
+
+ SnapMetrics(ImmutableViewportMetrics aMetrics, float aScrollChange) {
+ viewportWidth = aMetrics.viewportRectWidth;
+ viewportHeight = aMetrics.viewportRectHeight;
+ scrollChangeY = aScrollChange;
+ }
+ }
+}