1// Copyright 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.android_webview;
6
7import android.graphics.Rect;
8import android.widget.OverScroller;
9
10import com.google.common.annotations.VisibleForTesting;
11
12/**
13 * Takes care of syncing the scroll offset between the Android View system and the
14 * InProcessViewRenderer.
15 *
16 * Unless otherwise values (sizes, scroll offsets) are in physical pixels.
17 */
18@VisibleForTesting
19public class AwScrollOffsetManager {
20    // Values taken from WebViewClassic.
21
22    // The amount of content to overlap between two screens when using pageUp/pageDown methiods.
23    private static final int PAGE_SCROLL_OVERLAP = 24;
24    // Standard animated scroll speed.
25    private static final int STD_SCROLL_ANIMATION_SPEED_PIX_PER_SEC = 480;
26    // Time for the longest scroll animation.
27    private static final int MAX_SCROLL_ANIMATION_DURATION_MILLISEC = 750;
28
29    // The unit of all the values in this delegate are physical pixels.
30    public interface Delegate {
31        // Call View#overScrollBy on the containerView.
32        void overScrollContainerViewBy(int deltaX, int deltaY, int scrollX, int scrollY,
33                int scrollRangeX, int scrollRangeY, boolean isTouchEvent);
34        // Call View#scrollTo on the containerView.
35        void scrollContainerViewTo(int x, int y);
36        // Store the scroll offset in the native side. This should really be a simple store
37        // operation, the native side shouldn't synchronously alter the scroll offset from within
38        // this call.
39        void scrollNativeTo(int x, int y);
40
41        int getContainerViewScrollX();
42        int getContainerViewScrollY();
43
44        void invalidate();
45    }
46
47    private final Delegate mDelegate;
48
49    // Scroll offset as seen by the native side.
50    private int mNativeScrollX;
51    private int mNativeScrollY;
52
53    // How many pixels can we scroll in a given direction.
54    private int mMaxHorizontalScrollOffset;
55    private int mMaxVerticalScrollOffset;
56
57    // Size of the container view.
58    private int mContainerViewWidth;
59    private int mContainerViewHeight;
60
61    // Whether we're in the middle of processing a touch event.
62    private boolean mProcessingTouchEvent;
63
64    private boolean mFlinging;
65
66    // Whether (and to what value) to update the native side scroll offset after we've finished
67    // processing a touch event.
68    private boolean mApplyDeferredNativeScroll;
69    private int mDeferredNativeScrollX;
70    private int mDeferredNativeScrollY;
71
72    // The velocity of the last recorded fling,
73    private int mLastFlingVelocityX;
74    private int mLastFlingVelocityY;
75
76    private OverScroller mScroller;
77
78    public AwScrollOffsetManager(Delegate delegate, OverScroller overScroller) {
79        mDelegate = delegate;
80        mScroller = overScroller;
81    }
82
83    //----- Scroll range and extent calculation methods -------------------------------------------
84
85    public int computeHorizontalScrollRange() {
86        return mContainerViewWidth + mMaxHorizontalScrollOffset;
87    }
88
89    public int computeMaximumHorizontalScrollOffset() {
90        return mMaxHorizontalScrollOffset;
91    }
92
93    public int computeHorizontalScrollOffset() {
94        return mDelegate.getContainerViewScrollX();
95    }
96
97    public int computeVerticalScrollRange() {
98        return mContainerViewHeight + mMaxVerticalScrollOffset;
99    }
100
101    public int computeMaximumVerticalScrollOffset() {
102        return mMaxVerticalScrollOffset;
103    }
104
105    public int computeVerticalScrollOffset() {
106        return mDelegate.getContainerViewScrollY();
107    }
108
109    public int computeVerticalScrollExtent() {
110        return mContainerViewHeight;
111    }
112
113    //---------------------------------------------------------------------------------------------
114    // Called when the scroll range changes. This needs to be the size of the on-screen content.
115    public void setMaxScrollOffset(int width, int height) {
116        mMaxHorizontalScrollOffset = width;
117        mMaxVerticalScrollOffset = height;
118    }
119
120    // Called when the physical size of the view changes.
121    public void setContainerViewSize(int width, int height) {
122        mContainerViewWidth = width;
123        mContainerViewHeight = height;
124    }
125
126    public void syncScrollOffsetFromOnDraw() {
127        // Unfortunately apps override onScrollChanged without calling super which is why we need
128        // to sync the scroll offset on every onDraw.
129        onContainerViewScrollChanged(mDelegate.getContainerViewScrollX(),
130                mDelegate.getContainerViewScrollY());
131    }
132
133    public void setProcessingTouchEvent(boolean processingTouchEvent) {
134        assert mProcessingTouchEvent != processingTouchEvent;
135        mProcessingTouchEvent = processingTouchEvent;
136
137        if (!mProcessingTouchEvent && mApplyDeferredNativeScroll) {
138            mApplyDeferredNativeScroll = false;
139            scrollNativeTo(mDeferredNativeScrollX, mDeferredNativeScrollY);
140        }
141    }
142
143    // Called by the native side to scroll the container view.
144    public void scrollContainerViewTo(int x, int y) {
145        mNativeScrollX = x;
146        mNativeScrollY = y;
147
148        final int scrollX = mDelegate.getContainerViewScrollX();
149        final int scrollY = mDelegate.getContainerViewScrollY();
150        final int deltaX = x - scrollX;
151        final int deltaY = y - scrollY;
152        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
153        final int scrollRangeY = computeMaximumVerticalScrollOffset();
154
155        // We use overScrollContainerViewBy to be compatible with WebViewClassic which used this
156        // method for handling both over-scroll as well as in-bounds scroll.
157        mDelegate.overScrollContainerViewBy(deltaX, deltaY, scrollX, scrollY,
158                scrollRangeX, scrollRangeY, mProcessingTouchEvent);
159    }
160
161    public boolean isFlingActive() {
162        return mFlinging;
163    }
164
165    // Called by the native side to over-scroll the container view.
166    public void overScrollBy(int deltaX, int deltaY) {
167        // TODO(mkosiba): Once http://crbug.com/260663 and http://crbug.com/261239 are fixed it
168        // should be possible to uncomment the following asserts:
169        // if (deltaX < 0) assert mDelegate.getContainerViewScrollX() == 0;
170        // if (deltaX > 0) assert mDelegate.getContainerViewScrollX() ==
171        //          computeMaximumHorizontalScrollOffset();
172        scrollBy(deltaX, deltaY);
173    }
174
175    private void scrollBy(int deltaX, int deltaY) {
176        if (deltaX == 0 && deltaY == 0) return;
177
178        final int scrollX = mDelegate.getContainerViewScrollX();
179        final int scrollY = mDelegate.getContainerViewScrollY();
180        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
181        final int scrollRangeY = computeMaximumVerticalScrollOffset();
182
183        // The android.view.View.overScrollBy method is used for both scrolling and over-scrolling
184        // which is why we use it here.
185        mDelegate.overScrollContainerViewBy(deltaX, deltaY, scrollX, scrollY,
186                scrollRangeX, scrollRangeY, mProcessingTouchEvent);
187    }
188
189    private int clampHorizontalScroll(int scrollX) {
190        scrollX = Math.max(0, scrollX);
191        scrollX = Math.min(computeMaximumHorizontalScrollOffset(), scrollX);
192        return scrollX;
193    }
194
195    private int clampVerticalScroll(int scrollY) {
196        scrollY = Math.max(0, scrollY);
197        scrollY = Math.min(computeMaximumVerticalScrollOffset(), scrollY);
198        return scrollY;
199    }
200
201    // Called by the View system as a response to the mDelegate.overScrollContainerViewBy call.
202    public void onContainerViewOverScrolled(int scrollX, int scrollY, boolean clampedX,
203            boolean clampedY) {
204        // Clamp the scroll offset at (0, max).
205        scrollX = clampHorizontalScroll(scrollX);
206        scrollY = clampVerticalScroll(scrollY);
207
208        mDelegate.scrollContainerViewTo(scrollX, scrollY);
209
210        // This is only necessary if the containerView scroll offset ends up being different
211        // than the one set from native in which case we want the value stored on the native side
212        // to reflect the value stored in the containerView (and not the other way around).
213        scrollNativeTo(mDelegate.getContainerViewScrollX(), mDelegate.getContainerViewScrollY());
214    }
215
216    // Called by the View system when the scroll offset had changed. This might not get called if
217    // the embedder overrides WebView#onScrollChanged without calling super.onScrollChanged. If
218    // this method does get called it is called both as a response to the embedder scrolling the
219    // view as well as a response to mDelegate.scrollContainerViewTo.
220    public void onContainerViewScrollChanged(int x, int y) {
221        scrollNativeTo(x, y);
222    }
223
224    private void scrollNativeTo(int x, int y) {
225        x = clampHorizontalScroll(x);
226        y = clampVerticalScroll(y);
227
228        // We shouldn't do the store to native while processing a touch event since that confuses
229        // the gesture processing logic.
230        if (mProcessingTouchEvent) {
231            mDeferredNativeScrollX = x;
232            mDeferredNativeScrollY = y;
233            mApplyDeferredNativeScroll = true;
234            return;
235        }
236
237        if (x == mNativeScrollX && y == mNativeScrollY)
238            return;
239
240        // The scrollNativeTo call should be a simple store, so it's OK to assume it always
241        // succeeds.
242        mNativeScrollX = x;
243        mNativeScrollY = y;
244
245        mDelegate.scrollNativeTo(x, y);
246    }
247
248    // Called at the beginning of every fling gesture.
249    public void onFlingStartGesture(int velocityX, int velocityY) {
250        mLastFlingVelocityX = velocityX;
251        mLastFlingVelocityY = velocityY;
252    }
253
254    // Called whenever some other touch interaction requires the fling gesture to be canceled.
255    public void onFlingCancelGesture() {
256        // TODO(mkosiba): Support speeding up a fling by flinging again.
257        // http://crbug.com/265841
258        mScroller.forceFinished(true);
259    }
260
261    // Called when a fling gesture is not handled by the renderer.
262    // We explicitly ask the renderer not to handle fling gestures targeted at the root
263    // scroll layer.
264    public void onUnhandledFlingStartEvent() {
265        flingScroll(-mLastFlingVelocityX, -mLastFlingVelocityY);
266    }
267
268    // Starts the fling animation. Called both as a response to a fling gesture and as via the
269    // public WebView#flingScroll(int, int) API.
270    public void flingScroll(int velocityX, int velocityY) {
271        final int scrollX = mDelegate.getContainerViewScrollX();
272        final int scrollY = mDelegate.getContainerViewScrollY();
273        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
274        final int scrollRangeY = computeMaximumVerticalScrollOffset();
275
276        mScroller.fling(scrollX, scrollY, velocityX, velocityY,
277                0, scrollRangeX, 0, scrollRangeY);
278        mFlinging = true;
279        mDelegate.invalidate();
280    }
281
282    // Called immediately before the draw to update the scroll offset.
283    public void computeScrollAndAbsorbGlow(OverScrollGlow overScrollGlow) {
284        final boolean stillAnimating = mScroller.computeScrollOffset();
285        if (!stillAnimating) {
286            mFlinging = false;
287            return;
288        }
289
290        final int oldX = mDelegate.getContainerViewScrollX();
291        final int oldY = mDelegate.getContainerViewScrollY();
292        int x = mScroller.getCurrX();
293        int y = mScroller.getCurrY();
294
295        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
296        final int scrollRangeY = computeMaximumVerticalScrollOffset();
297
298        if (overScrollGlow != null) {
299            overScrollGlow.absorbGlow(x, y, oldX, oldY, scrollRangeX, scrollRangeY,
300                    mScroller.getCurrVelocity());
301        }
302
303        // The mScroller is configured not to go outside of the scrollable range, so this call
304        // should never result in attempting to scroll outside of the scrollable region.
305        scrollBy(x - oldX, y - oldY);
306
307        mDelegate.invalidate();
308    }
309
310    private static int computeDurationInMilliSec(int dx, int dy) {
311        int distance = Math.max(Math.abs(dx), Math.abs(dy));
312        int duration = distance * 1000 / STD_SCROLL_ANIMATION_SPEED_PIX_PER_SEC;
313        return Math.min(duration, MAX_SCROLL_ANIMATION_DURATION_MILLISEC);
314    }
315
316    private boolean animateScrollTo(int x, int y) {
317        final int scrollX = mDelegate.getContainerViewScrollX();
318        final int scrollY = mDelegate.getContainerViewScrollY();
319
320        x = clampHorizontalScroll(x);
321        y = clampVerticalScroll(y);
322
323        int dx = x - scrollX;
324        int dy = y - scrollY;
325
326        if (dx == 0 && dy == 0)
327            return false;
328
329        mScroller.startScroll(scrollX, scrollY, dx, dy, computeDurationInMilliSec(dx, dy));
330        mDelegate.invalidate();
331
332        return true;
333    }
334
335    /**
336     * See {@link WebView#pageUp(boolean)}
337     */
338    public boolean pageUp(boolean top) {
339        final int scrollX = mDelegate.getContainerViewScrollX();
340        final int scrollY = mDelegate.getContainerViewScrollY();
341
342        if (top) {
343            // go to the top of the document
344            return animateScrollTo(scrollX, 0);
345        }
346        int dy = -mContainerViewHeight / 2;
347        if (mContainerViewHeight > 2 * PAGE_SCROLL_OVERLAP) {
348            dy = -mContainerViewHeight + PAGE_SCROLL_OVERLAP;
349        }
350        // animateScrollTo clamps the argument to the scrollable range so using (scrollY + dy) is
351        // fine.
352        return animateScrollTo(scrollX, scrollY + dy);
353    }
354
355    /**
356     * See {@link WebView#pageDown(boolean)}
357     */
358    public boolean pageDown(boolean bottom) {
359        final int scrollX = mDelegate.getContainerViewScrollX();
360        final int scrollY = mDelegate.getContainerViewScrollY();
361
362        if (bottom) {
363            return animateScrollTo(scrollX, computeVerticalScrollRange());
364        }
365        int dy = mContainerViewHeight / 2;
366        if (mContainerViewHeight > 2 * PAGE_SCROLL_OVERLAP) {
367            dy = mContainerViewHeight - PAGE_SCROLL_OVERLAP;
368        }
369        // animateScrollTo clamps the argument to the scrollable range so using (scrollY + dy) is
370        // fine.
371        return animateScrollTo(scrollX, scrollY + dy);
372    }
373
374    /**
375     * See {@link WebView#requestChildRectangleOnScreen(View, Rect, boolean)}
376     */
377    public boolean requestChildRectangleOnScreen(int childOffsetX, int childOffsetY, Rect rect,
378            boolean immediate) {
379        // TODO(mkosiba): WebViewClassic immediately returns false if a zoom animation is
380        // in progress. We currently can't tell if one is happening.. should we instead cancel any
381        // scroll animation when the size/pageScaleFactor changes?
382
383        // TODO(mkosiba): Take scrollbar width into account in the screenRight/screenBotton
384        // calculations. http://crbug.com/269032
385
386        final int scrollX = mDelegate.getContainerViewScrollX();
387        final int scrollY = mDelegate.getContainerViewScrollY();
388
389        rect.offset(childOffsetX, childOffsetY);
390
391        int screenTop = scrollY;
392        int screenBottom = scrollY + mContainerViewHeight;
393        int scrollYDelta = 0;
394
395        if (rect.bottom > screenBottom) {
396            int oneThirdOfScreenHeight = mContainerViewHeight / 3;
397            if (rect.width() > 2 * oneThirdOfScreenHeight) {
398                // If the rectangle is too tall to fit in the bottom two thirds
399                // of the screen, place it at the top.
400                scrollYDelta = rect.top - screenTop;
401            } else {
402                // If the rectangle will still fit on screen, we want its
403                // top to be in the top third of the screen.
404                scrollYDelta = rect.top - (screenTop + oneThirdOfScreenHeight);
405            }
406        } else if (rect.top < screenTop) {
407            scrollYDelta = rect.top - screenTop;
408        }
409
410        int screenLeft = scrollX;
411        int screenRight = scrollX + mContainerViewWidth;
412        int scrollXDelta = 0;
413
414        if (rect.right > screenRight && rect.left > screenLeft) {
415            if (rect.width() > mContainerViewWidth) {
416                scrollXDelta += (rect.left - screenLeft);
417            } else {
418                scrollXDelta += (rect.right - screenRight);
419            }
420        } else if (rect.left < screenLeft) {
421            scrollXDelta -= (screenLeft - rect.left);
422        }
423
424        if (scrollYDelta == 0 && scrollXDelta == 0) {
425            return false;
426        }
427
428        if (immediate) {
429            scrollBy(scrollXDelta, scrollYDelta);
430            return true;
431        } else {
432            return animateScrollTo(scrollX + scrollXDelta, scrollY + scrollYDelta);
433        }
434    }
435}
436