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