/* -*- 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.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.animation.DecelerateInterpolator; import android.view.MotionEvent; import java.util.ArrayList; import java.util.List; public class DynamicToolbarAnimator { private static final String LOGTAG = "GeckoDynamicToolbarAnimator"; private static final String PREF_SCROLL_TOOLBAR_THRESHOLD = "browser.ui.scroll-toolbar-threshold"; // The duration of the animation in ns private static final long ANIMATION_DURATION = 250000000; private final GeckoLayerClient mTarget; private final List 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; /* If this boolean is true, scroll changes will not affect translation */ private boolean mPinned; /* This interpolator is used for the above mentioned animation */ private DecelerateInterpolator 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 Integer mPrefObserverId; /* 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; public DynamicToolbarAnimator(GeckoLayerClient aTarget) { mTarget = aTarget; mListeners = new ArrayList(); mInterpolator = new DecelerateInterpolator(); // Listen to the dynamic toolbar pref mPrefObserverId = PrefsHelper.getPref(PREF_SCROLL_TOOLBAR_THRESHOLD, new PrefsHelper.PrefHandlerBase() { @Override public void prefValue(String pref, int value) { SCROLL_TOOLBAR_THRESHOLD = value / 100.0f; } @Override public boolean isObserver() { return true; } }); } public void destroy() { if (mPrefObserverId != null) { PrefsHelper.removeObserver(mPrefObserverId); mPrefObserverId = null; } } 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; } public void setPinned(boolean pinned) { mPinned = pinned; } public boolean isPinned() { return mPinned; } public void showToolbar(boolean immediately) { animateToolbar(true, immediately); } public void hideToolbar(boolean immediately) { animateToolbar(false, immediately); } 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()) { 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. boolean inBetween = (mToolbarTranslation != 0 && mToolbarTranslation != mMaxTranslation); boolean reachedThreshold = -aTouchTravelDistance >= exposeThreshold; boolean atBottomOfPage = aMetrics.viewportRectBottom() >= aMetrics.pageRectBottom; if (inBetween || (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; } boolean onInterceptTouchEvent(MotionEvent event) { if (mPinned) { 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.getActionMasked() != MotionEvent.ACTION_MOVE || event.getPointerCount() != 1) { if (mTouchStart != null) { Log.v(LOGTAG, "Resetting touch sequence due to non-move"); mTouchStart = null; } 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))) { Log.v(LOGTAG, "Direction changed: " + mTouchStart.y + " -> " + mLastTouch + " -> " + event.getRawY()); // If the direction of movement changed, reset the travel // distance properties. mTouchStart = null; } } if (mTouchStart == null) { mTouchStart = new PointF(event.getRawX(), event.getRawY()); mLastTouch = event.getRawY(); return false; } float deltaY = event.getRawY() - mLastTouch; 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); Log.v(LOGTAG, "Got vertical translation " + translation); 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; } fireListeners(); mTarget.getView().requestRender(); return true; } 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; } } }