// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.android_webview;
import android.graphics.Rect;
import android.widget.OverScroller;
import com.google.common.annotations.VisibleForTesting;
/**
* Takes care of syncing the scroll offset between the Android View system and the
* InProcessViewRenderer.
*
* Unless otherwise values (sizes, scroll offsets) are in physical pixels.
*/
@VisibleForTesting
public class AwScrollOffsetManager {
// Values taken from WebViewClassic.
// The amount of content to overlap between two screens when using pageUp/pageDown methiods.
private static final int PAGE_SCROLL_OVERLAP = 24;
// Standard animated scroll speed.
private static final int STD_SCROLL_ANIMATION_SPEED_PIX_PER_SEC = 480;
// Time for the longest scroll animation.
private static final int MAX_SCROLL_ANIMATION_DURATION_MILLISEC = 750;
/**
* The interface that all users of AwScrollOffsetManager should implement.
*
* The unit of all the values in this delegate are physical pixels.
*/
public interface Delegate {
// Call View#overScrollBy on the containerView.
void overScrollContainerViewBy(int deltaX, int deltaY, int scrollX, int scrollY,
int scrollRangeX, int scrollRangeY, boolean isTouchEvent);
// Call View#scrollTo on the containerView.
void scrollContainerViewTo(int x, int y);
// Store the scroll offset in the native side. This should really be a simple store
// operation, the native side shouldn't synchronously alter the scroll offset from within
// this call.
void scrollNativeTo(int x, int y);
int getContainerViewScrollX();
int getContainerViewScrollY();
void invalidate();
}
private final Delegate mDelegate;
// Scroll offset as seen by the native side.
private int mNativeScrollX;
private int mNativeScrollY;
// How many pixels can we scroll in a given direction.
private int mMaxHorizontalScrollOffset;
private int mMaxVerticalScrollOffset;
// Size of the container view.
private int mContainerViewWidth;
private int mContainerViewHeight;
// Whether we're in the middle of processing a touch event.
private boolean mProcessingTouchEvent;
private boolean mFlinging;
// Whether (and to what value) to update the native side scroll offset after we've finished
// processing a touch event.
private boolean mApplyDeferredNativeScroll;
private int mDeferredNativeScrollX;
private int mDeferredNativeScrollY;
private OverScroller mScroller;
public AwScrollOffsetManager(Delegate delegate, OverScroller overScroller) {
mDelegate = delegate;
mScroller = overScroller;
}
//----- Scroll range and extent calculation methods -------------------------------------------
public int computeHorizontalScrollRange() {
return mContainerViewWidth + mMaxHorizontalScrollOffset;
}
public int computeMaximumHorizontalScrollOffset() {
return mMaxHorizontalScrollOffset;
}
public int computeHorizontalScrollOffset() {
return mDelegate.getContainerViewScrollX();
}
public int computeVerticalScrollRange() {
return mContainerViewHeight + mMaxVerticalScrollOffset;
}
public int computeMaximumVerticalScrollOffset() {
return mMaxVerticalScrollOffset;
}
public int computeVerticalScrollOffset() {
return mDelegate.getContainerViewScrollY();
}
public int computeVerticalScrollExtent() {
return mContainerViewHeight;
}
//---------------------------------------------------------------------------------------------
/**
* Called when the scroll range changes. This needs to be the size of the on-screen content.
*/
public void setMaxScrollOffset(int width, int height) {
mMaxHorizontalScrollOffset = width;
mMaxVerticalScrollOffset = height;
}
/**
* Called when the physical size of the view changes.
*/
public void setContainerViewSize(int width, int height) {
mContainerViewWidth = width;
mContainerViewHeight = height;
}
public void syncScrollOffsetFromOnDraw() {
// Unfortunately apps override onScrollChanged without calling super which is why we need
// to sync the scroll offset on every onDraw.
onContainerViewScrollChanged(mDelegate.getContainerViewScrollX(),
mDelegate.getContainerViewScrollY());
}
public void setProcessingTouchEvent(boolean processingTouchEvent) {
assert mProcessingTouchEvent != processingTouchEvent;
mProcessingTouchEvent = processingTouchEvent;
if (!mProcessingTouchEvent && mApplyDeferredNativeScroll) {
mApplyDeferredNativeScroll = false;
scrollNativeTo(mDeferredNativeScrollX, mDeferredNativeScrollY);
}
}
// Called by the native side to scroll the container view.
public void scrollContainerViewTo(int x, int y) {
mNativeScrollX = x;
mNativeScrollY = y;
final int scrollX = mDelegate.getContainerViewScrollX();
final int scrollY = mDelegate.getContainerViewScrollY();
final int deltaX = x - scrollX;
final int deltaY = y - scrollY;
final int scrollRangeX = computeMaximumHorizontalScrollOffset();
final int scrollRangeY = computeMaximumVerticalScrollOffset();
// We use overScrollContainerViewBy to be compatible with WebViewClassic which used this
// method for handling both over-scroll as well as in-bounds scroll.
mDelegate.overScrollContainerViewBy(deltaX, deltaY, scrollX, scrollY,
scrollRangeX, scrollRangeY, mProcessingTouchEvent);
}
public boolean isFlingActive() {
return mFlinging;
}
// Called by the native side to over-scroll the container view.
public void overScrollBy(int deltaX, int deltaY) {
// TODO(mkosiba): Once http://crbug.com/260663 and http://crbug.com/261239 are fixed it
// should be possible to uncomment the following asserts:
// if (deltaX < 0) assert mDelegate.getContainerViewScrollX() == 0;
// if (deltaX > 0) assert mDelegate.getContainerViewScrollX() ==
// computeMaximumHorizontalScrollOffset();
scrollBy(deltaX, deltaY);
}
private void scrollBy(int deltaX, int deltaY) {
if (deltaX == 0 && deltaY == 0) return;
final int scrollX = mDelegate.getContainerViewScrollX();
final int scrollY = mDelegate.getContainerViewScrollY();
final int scrollRangeX = computeMaximumHorizontalScrollOffset();
final int scrollRangeY = computeMaximumVerticalScrollOffset();
// The android.view.View.overScrollBy method is used for both scrolling and over-scrolling
// which is why we use it here.
mDelegate.overScrollContainerViewBy(deltaX, deltaY, scrollX, scrollY,
scrollRangeX, scrollRangeY, mProcessingTouchEvent);
}
private int clampHorizontalScroll(int scrollX) {
scrollX = Math.max(0, scrollX);
scrollX = Math.min(computeMaximumHorizontalScrollOffset(), scrollX);
return scrollX;
}
private int clampVerticalScroll(int scrollY) {
scrollY = Math.max(0, scrollY);
scrollY = Math.min(computeMaximumVerticalScrollOffset(), scrollY);
return scrollY;
}
// Called by the View system as a response to the mDelegate.overScrollContainerViewBy call.
public void onContainerViewOverScrolled(int scrollX, int scrollY, boolean clampedX,
boolean clampedY) {
// Clamp the scroll offset at (0, max).
scrollX = clampHorizontalScroll(scrollX);
scrollY = clampVerticalScroll(scrollY);
mDelegate.scrollContainerViewTo(scrollX, scrollY);
// This is only necessary if the containerView scroll offset ends up being different
// than the one set from native in which case we want the value stored on the native side
// to reflect the value stored in the containerView (and not the other way around).
scrollNativeTo(mDelegate.getContainerViewScrollX(), mDelegate.getContainerViewScrollY());
}
// Called by the View system when the scroll offset had changed. This might not get called if
// the embedder overrides WebView#onScrollChanged without calling super.onScrollChanged. If
// this method does get called it is called both as a response to the embedder scrolling the
// view as well as a response to mDelegate.scrollContainerViewTo.
public void onContainerViewScrollChanged(int x, int y) {
scrollNativeTo(x, y);
}
private void scrollNativeTo(int x, int y) {
x = clampHorizontalScroll(x);
y = clampVerticalScroll(y);
// We shouldn't do the store to native while processing a touch event since that confuses
// the gesture processing logic.
if (mProcessingTouchEvent) {
mDeferredNativeScrollX = x;
mDeferredNativeScrollY = y;
mApplyDeferredNativeScroll = true;
return;
}
if (x == mNativeScrollX && y == mNativeScrollY)
return;
// The scrollNativeTo call should be a simple store, so it's OK to assume it always
// succeeds.
mNativeScrollX = x;
mNativeScrollY = y;
mDelegate.scrollNativeTo(x, y);
}
// Called whenever some other touch interaction requires the fling gesture to be canceled.
public void onFlingCancelGesture() {
// TODO(mkosiba): Support speeding up a fling by flinging again.
// http://crbug.com/265841
mScroller.forceFinished(true);
}
// Called when a fling gesture is not handled by the renderer.
// We explicitly ask the renderer not to handle fling gestures targeted at the root
// scroll layer.
public void onUnhandledFlingStartEvent(int velocityX, int velocityY) {
flingScroll(-velocityX, -velocityY);
}
// Starts the fling animation. Called both as a response to a fling gesture and as via the
// public WebView#flingScroll(int, int) API.
public void flingScroll(int velocityX, int velocityY) {
final int scrollX = mDelegate.getContainerViewScrollX();
final int scrollY = mDelegate.getContainerViewScrollY();
final int scrollRangeX = computeMaximumHorizontalScrollOffset();
final int scrollRangeY = computeMaximumVerticalScrollOffset();
mScroller.fling(scrollX, scrollY, velocityX, velocityY,
0, scrollRangeX, 0, scrollRangeY);
mDelegate.invalidate();
}
// Called immediately before the draw to update the scroll offset.
public void computeScrollAndAbsorbGlow(OverScrollGlow overScrollGlow) {
mFlinging = mScroller.computeScrollOffset();
if (!mFlinging) {
return;
}
final int oldX = mDelegate.getContainerViewScrollX();
final int oldY = mDelegate.getContainerViewScrollY();
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
final int scrollRangeX = computeMaximumHorizontalScrollOffset();
final int scrollRangeY = computeMaximumVerticalScrollOffset();
if (overScrollGlow != null) {
overScrollGlow.absorbGlow(x, y, oldX, oldY, scrollRangeX, scrollRangeY,
mScroller.getCurrVelocity());
}
// The mScroller is configured not to go outside of the scrollable range, so this call
// should never result in attempting to scroll outside of the scrollable region.
scrollBy(x - oldX, y - oldY);
mDelegate.invalidate();
}
private static int computeDurationInMilliSec(int dx, int dy) {
int distance = Math.max(Math.abs(dx), Math.abs(dy));
int duration = distance * 1000 / STD_SCROLL_ANIMATION_SPEED_PIX_PER_SEC;
return Math.min(duration, MAX_SCROLL_ANIMATION_DURATION_MILLISEC);
}
private boolean animateScrollTo(int x, int y) {
final int scrollX = mDelegate.getContainerViewScrollX();
final int scrollY = mDelegate.getContainerViewScrollY();
x = clampHorizontalScroll(x);
y = clampVerticalScroll(y);
int dx = x - scrollX;
int dy = y - scrollY;
if (dx == 0 && dy == 0)
return false;
mScroller.startScroll(scrollX, scrollY, dx, dy, computeDurationInMilliSec(dx, dy));
mDelegate.invalidate();
return true;
}
/**
* See {@link android.webkit.WebView#pageUp(boolean)}
*/
public boolean pageUp(boolean top) {
final int scrollX = mDelegate.getContainerViewScrollX();
final int scrollY = mDelegate.getContainerViewScrollY();
if (top) {
// go to the top of the document
return animateScrollTo(scrollX, 0);
}
int dy = -mContainerViewHeight / 2;
if (mContainerViewHeight > 2 * PAGE_SCROLL_OVERLAP) {
dy = -mContainerViewHeight + PAGE_SCROLL_OVERLAP;
}
// animateScrollTo clamps the argument to the scrollable range so using (scrollY + dy) is
// fine.
return animateScrollTo(scrollX, scrollY + dy);
}
/**
* See {@link android.webkit.WebView#pageDown(boolean)}
*/
public boolean pageDown(boolean bottom) {
final int scrollX = mDelegate.getContainerViewScrollX();
final int scrollY = mDelegate.getContainerViewScrollY();
if (bottom) {
return animateScrollTo(scrollX, computeVerticalScrollRange());
}
int dy = mContainerViewHeight / 2;
if (mContainerViewHeight > 2 * PAGE_SCROLL_OVERLAP) {
dy = mContainerViewHeight - PAGE_SCROLL_OVERLAP;
}
// animateScrollTo clamps the argument to the scrollable range so using (scrollY + dy) is
// fine.
return animateScrollTo(scrollX, scrollY + dy);
}
/**
* See {@link android.webkit.WebView#requestChildRectangleOnScreen(View, Rect, boolean)}
*/
public boolean requestChildRectangleOnScreen(int childOffsetX, int childOffsetY, Rect rect,
boolean immediate) {
// TODO(mkosiba): WebViewClassic immediately returns false if a zoom animation is
// in progress. We currently can't tell if one is happening.. should we instead cancel any
// scroll animation when the size/pageScaleFactor changes?
// TODO(mkosiba): Take scrollbar width into account in the screenRight/screenBotton
// calculations. http://crbug.com/269032
final int scrollX = mDelegate.getContainerViewScrollX();
final int scrollY = mDelegate.getContainerViewScrollY();
rect.offset(childOffsetX, childOffsetY);
int screenTop = scrollY;
int screenBottom = scrollY + mContainerViewHeight;
int scrollYDelta = 0;
if (rect.bottom > screenBottom) {
int oneThirdOfScreenHeight = mContainerViewHeight / 3;
if (rect.width() > 2 * oneThirdOfScreenHeight) {
// If the rectangle is too tall to fit in the bottom two thirds
// of the screen, place it at the top.
scrollYDelta = rect.top - screenTop;
} else {
// If the rectangle will still fit on screen, we want its
// top to be in the top third of the screen.
scrollYDelta = rect.top - (screenTop + oneThirdOfScreenHeight);
}
} else if (rect.top < screenTop) {
scrollYDelta = rect.top - screenTop;
}
int screenLeft = scrollX;
int screenRight = scrollX + mContainerViewWidth;
int scrollXDelta = 0;
if (rect.right > screenRight && rect.left > screenLeft) {
if (rect.width() > mContainerViewWidth) {
scrollXDelta += (rect.left - screenLeft);
} else {
scrollXDelta += (rect.right - screenRight);
}
} else if (rect.left < screenLeft) {
scrollXDelta -= (screenLeft - rect.left);
}
if (scrollYDelta == 0 && scrollXDelta == 0) {
return false;
}
if (immediate) {
scrollBy(scrollXDelta, scrollYDelta);
return true;
} else {
return animateScrollTo(scrollX + scrollXDelta, scrollY + scrollYDelta);
}
}
}