Search code examples
androidandroid-webviewandroid-toolbarandroid-appbarlayoutandroid-scroll

NestedWebView working properly with ScrollingViewBehavior


I have an app with two activities extends same abstract - one with WebView, another with ViewPager with TabLayout and RecyclerViews - and on this level I've implemented "shared" showing/hiding toolbar with scroll/fling feature:

app:layout_scrollFlags="scroll|snap|enterAlways"

WebView is "pushing out" Toolbar just like RecyclerView under any tab in ViewPager (keeping TabLayout visible), in both cases any scroll to top brings back Toolbar. Just like on this example:

hide with scroll

Now I've to change this behavior - Toolbar should be hidden when user is NOT on top of page, so: any scroll to bottom should hide Toolbar if present and only (scroll to top && getScrollY()<=0) should make Toolbar enter:

hide only on top

I've borrowed GIFs from THIS guide, which is suggesting (all other too) that my desired behavior is default with just scroll flag (and possibly snap). So I've removed enterAlways and my "native" Activity started to work as intended out-of-the-box. WebView - no change at all...

I'm suspecting that this bug is caused by my NestedWebView, I'm currently using THIS one. So I've tried to drop-in replace this class with these:

marshi - not scrollable webview content (toolbar shows/hides)

takahirom - toolbar enters even when 1px scroll in up direction almost immediate (kind of snap behavior, but during touch)

hanks - no nested scrolling (fixed toolbar), also with uncommented setNestedScrollingEnabled(true); line

How to achieve second GIF Toolbar behavior cooperating with WebView?


Solution

  • read edit on bottom and other answers/comments! don't use this class, also avoid WebView in any form if you can. You will get into trouble sooner or later, maintaining WebView is hell...

    I've found few different implementations of NestedWebView and noticed that all of them are old and implements NestedScrollingChild when currently we have 3rd version of this interface: NestedScrollingChild3 (androidx). So I've penetrated current version of NestedSrcollView and I've added some new methods handling, and now it works. To be exact - I was trying to achieve smooth scroll through whole screen just like when WebView would fill whole screen and Toolbar was part of web content (always on top). Also snap is working. use wisely

    import android.annotation.SuppressLint;
    import android.content.Context;
    import android.util.AttributeSet;
    import android.util.Log;
    import android.view.MotionEvent;
    import android.view.VelocityTracker;
    import android.view.View;
    import android.view.ViewConfiguration;
    import android.view.ViewParent;
    import android.webkit.WebView;
    import android.widget.OverScroller;
    
    import androidx.annotation.NonNull;
    import androidx.annotation.Nullable;
    import androidx.core.view.NestedScrollingChild3;
    import androidx.core.view.NestedScrollingChildHelper;
    import androidx.core.view.ViewCompat;
    
    /**
     * WebView compatible with CoordinatorLayout by snachmsm
     * The implementation based on NestedScrollView of design library androidx v1.0.1
     */
    public class NestedWebView extends WebView implements NestedScrollingChild3 {
    
        private static final String TAG = "NestedWebView";
        private static final int INVALID_POINTER = -1;
    
        private final int[] mScrollOffset = new int[2];
        private final int[] mScrollConsumed = new int[2];
    
        private int mLastMotionY;
        private NestedScrollingChildHelper mChildHelper;
        private boolean mIsBeingDragged = false;
        private VelocityTracker mVelocityTracker;
        private int mTouchSlop;
        private int mActivePointerId = INVALID_POINTER;
        private int mNestedYOffset;
        private OverScroller mScroller;
        private int mMinimumVelocity;
        private int mMaximumVelocity;
        private int mLastScrollerY;
    
        public NestedWebView(Context context) {
            this(context, null);
        }
    
        public NestedWebView(Context context, AttributeSet attrs) {
            this(context, attrs, android.R.attr.webViewStyle);
        }
    
        public NestedWebView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            setOverScrollMode(WebView.OVER_SCROLL_NEVER);
            initScrollView();
            mChildHelper = new NestedScrollingChildHelper(this);
            setNestedScrollingEnabled(true);
        }
    
        private void initScrollView() {
            mScroller = new OverScroller(getContext());
            final ViewConfiguration configuration = ViewConfiguration.get(getContext());
            mTouchSlop = configuration.getScaledTouchSlop();
            mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
            mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
        }
    
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            final int action = ev.getAction();
            if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) { // most common
                return true;
            }
    
            switch (action & MotionEvent.ACTION_MASK) {
                case MotionEvent.ACTION_MOVE:
                    final int activePointerId = mActivePointerId;
                    if (activePointerId == INVALID_POINTER) {
                        break;
                    }
    
                    final int pointerIndex = ev.findPointerIndex(activePointerId);
                    if (pointerIndex == -1) {
                        Log.e(TAG, "Invalid pointerId=" + activePointerId
                                + " in onInterceptTouchEvent");
                        break;
                    }
    
                    final int y = (int) ev.getY(pointerIndex);
                    final int yDiff = Math.abs(y - mLastMotionY);
                    if (yDiff > mTouchSlop
                            && (getNestedScrollAxes() & ViewCompat.SCROLL_AXIS_VERTICAL) == 0) {
                        mIsBeingDragged = true;
                        mLastMotionY = y;
                        initVelocityTrackerIfNotExists();
                        mVelocityTracker.addMovement(ev);
                        mNestedYOffset = 0;
                        final ViewParent parent = getParent();
                        if (parent != null) {
                            parent.requestDisallowInterceptTouchEvent(true);
                        }
                    }
                    break;
                case MotionEvent.ACTION_DOWN:
                    mLastMotionY = (int) ev.getY();
                    mActivePointerId = ev.getPointerId(0);
    
                    initOrResetVelocityTracker();
                    mVelocityTracker.addMovement(ev);
    
                    mScroller.computeScrollOffset();
                    mIsBeingDragged = !mScroller.isFinished();
    
                    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
                    break;
                case MotionEvent.ACTION_CANCEL:
                case MotionEvent.ACTION_UP:
                    mIsBeingDragged = false;
                    mActivePointerId = INVALID_POINTER;
                    recycleVelocityTracker();
                    if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) {
                        ViewCompat.postInvalidateOnAnimation(this);
                    }
                    stopNestedScroll();
                    break;
                case MotionEvent.ACTION_POINTER_UP:
                    onSecondaryPointerUp(ev);
                    break;
            }
    
            return mIsBeingDragged;
        }
    
        @SuppressLint("ClickableViewAccessibility")
        @Override
        public boolean onTouchEvent(MotionEvent ev) {
            initVelocityTrackerIfNotExists();
    
            MotionEvent vtev = MotionEvent.obtain(ev);
    
            final int actionMasked = ev.getActionMasked();
    
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                mNestedYOffset = 0;
            }
            vtev.offsetLocation(0, mNestedYOffset);
    
            switch (actionMasked) {
                case MotionEvent.ACTION_DOWN:
                    if ((mIsBeingDragged = !mScroller.isFinished())) {
                        final ViewParent parent = getParent();
                        if (parent != null) {
                            parent.requestDisallowInterceptTouchEvent(true);
                        }
                    }
    
                    if (!mScroller.isFinished()) {
                        abortAnimatedScroll();
                    }
    
                    mLastMotionY = (int) ev.getY();
                    mActivePointerId = ev.getPointerId(0);
                    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                    break;
                case MotionEvent.ACTION_MOVE:
                    final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (activePointerIndex == -1) {
                        Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                        break;
                    }
    
                    final int y = (int) ev.getY(activePointerIndex);
                    int deltaY = mLastMotionY - y;
                    if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                            ViewCompat.TYPE_TOUCH)) {
                        deltaY -= mScrollConsumed[1];
                        mNestedYOffset += mScrollOffset[1];
                    }
                    if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
                        final ViewParent parent = getParent();
                        if (parent != null) {
                            parent.requestDisallowInterceptTouchEvent(true);
                        }
                        mIsBeingDragged = true;
                        if (deltaY > 0) {
                            deltaY -= mTouchSlop;
                        } else {
                            deltaY += mTouchSlop;
                        }
                    }
                    if (mIsBeingDragged) {
                        mLastMotionY = y - mScrollOffset[1];
    
                        final int oldY = getScrollY();
                        final int range = getScrollRange();
    
                        // Calling overScrollByCompat will call onOverScrolled, which
                        // calls onScrollChanged if applicable.
                        if (overScrollByCompat(0, deltaY, 0, oldY, 0, range, 0,
                                0, true) && !hasNestedScrollingParent(ViewCompat.TYPE_TOUCH)) {
                            mVelocityTracker.clear();
                        }
    
                        final int scrolledDeltaY = getScrollY() - oldY;
                        final int unconsumedY = deltaY - scrolledDeltaY;
    
                        mScrollConsumed[1] = 0;
    
                        dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
                                ViewCompat.TYPE_TOUCH, mScrollConsumed);
    
                        mLastMotionY -= mScrollOffset[1];
                        mNestedYOffset += mScrollOffset[1];
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    final VelocityTracker velocityTracker = mVelocityTracker;
                    velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                    int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);
                    if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                        if (!dispatchNestedPreFling(0, -initialVelocity)) {
                            dispatchNestedFling(0, -initialVelocity, true);
                            fling(-initialVelocity);
                        }
                    } else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
                            getScrollRange())) {
                        ViewCompat.postInvalidateOnAnimation(this);
                    }
                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                    break;
                case MotionEvent.ACTION_CANCEL:
                    if (mIsBeingDragged) {
                        if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
                                getScrollRange())) {
                            ViewCompat.postInvalidateOnAnimation(this);
                        }
                    }
                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                    break;
                case MotionEvent.ACTION_POINTER_DOWN:
                    final int index = ev.getActionIndex();
                    mLastMotionY = (int) ev.getY(index);
                    mActivePointerId = ev.getPointerId(index);
                    break;
                case MotionEvent.ACTION_POINTER_UP:
                    onSecondaryPointerUp(ev);
                    mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId));
                    break;
            }
    
            if (mVelocityTracker != null) {
                mVelocityTracker.addMovement(vtev);
            }
            vtev.recycle();
            return super.onTouchEvent(ev);
        }
    
        private void abortAnimatedScroll() {
            mScroller.abortAnimation();
            stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
        }
    
        private void endDrag() {
            mIsBeingDragged = false;
    
            recycleVelocityTracker();
            stopNestedScroll();
        }
    
        private void onSecondaryPointerUp(MotionEvent ev) {
            final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
                    >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
            final int pointerId = ev.getPointerId(pointerIndex);
            if (pointerId == mActivePointerId) {
                final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
                mLastMotionY = (int) ev.getY(newPointerIndex);
                mActivePointerId = ev.getPointerId(newPointerIndex);
                if (mVelocityTracker != null) {
                    mVelocityTracker.clear();
                }
            }
        }
    
        private void fling(int velocityY) {
            int height = getHeight();
            mScroller.fling(getScrollX(), getScrollY(), // start
                    0, velocityY, // velocities
                    0, 0, // x
                    Integer.MIN_VALUE, Integer.MAX_VALUE, // y
                    0, height / 2);
            runAnimatedScroll(true);
        }
    
        private void runAnimatedScroll(boolean participateInNestedScrolling) {
            if (participateInNestedScrolling) {
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
            } else {
                stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
            }
            mLastScrollerY = getScrollY();
            ViewCompat.postInvalidateOnAnimation(this);
        }
    
        @Override
        public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
            if (disallowIntercept) {
                recycleVelocityTracker();
            }
            super.requestDisallowInterceptTouchEvent(disallowIntercept);
        }
    
        private void initOrResetVelocityTracker() {
            if (mVelocityTracker == null) {
                mVelocityTracker = VelocityTracker.obtain();
            } else {
                mVelocityTracker.clear();
            }
        }
    
        private void initVelocityTrackerIfNotExists() {
            if (mVelocityTracker == null) {
                mVelocityTracker = VelocityTracker.obtain();
            }
        }
    
        private void recycleVelocityTracker() {
            if (mVelocityTracker != null) {
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
        }
    
        @Override
        protected boolean overScrollBy(int deltaX, int deltaY,
                                       int scrollX, int scrollY,
                                       int scrollRangeX, int scrollRangeY,
                                       int maxOverScrollX, int maxOverScrollY,
                                       boolean isTouchEvent) {
            // this is causing double scroll call (doubled speed), but this WebView isn't overscrollable
            // all overscrolls are passed to appbar, so commenting this out during drag
            if (!mIsBeingDragged)
                overScrollByCompat(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY,
                        maxOverScrollX, maxOverScrollY, isTouchEvent);
            // without this call webview won't scroll to top when url change or when user pick input
            // (webview should move a bit making input still in viewport when "adjustResize")
            return true;
        }
    
        int getScrollRange() {
            //Using scroll range of webview instead of childs as NestedScrollView does.
            return computeVerticalScrollRange();
        }
    
        @Override
        public boolean isNestedScrollingEnabled() {
            return mChildHelper.isNestedScrollingEnabled();
        }
    
        @Override
        public void setNestedScrollingEnabled(boolean enabled) {
            mChildHelper.setNestedScrollingEnabled(enabled);
        }
    
        @Override
        public boolean startNestedScroll(int axes, int type) {
            return mChildHelper.startNestedScroll(axes, type);
        }
    
        @Override
        public boolean startNestedScroll(int axes) {
            return startNestedScroll(axes, ViewCompat.TYPE_TOUCH);
        }
    
        @Override
        public void stopNestedScroll(int type) {
            mChildHelper.stopNestedScroll(type);
        }
    
        @Override
        public void stopNestedScroll() {
            stopNestedScroll(ViewCompat.TYPE_TOUCH);
        }
    
        @Override
        public boolean hasNestedScrollingParent(int type) {
            return mChildHelper.hasNestedScrollingParent(type);
        }
    
        @Override
        public boolean hasNestedScrollingParent() {
            return hasNestedScrollingParent(ViewCompat.TYPE_TOUCH);
        }
    
        @Override
        public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
                                            int[] offsetInWindow) {
            return dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                    offsetInWindow, ViewCompat.TYPE_TOUCH);
        }
    
        @Override
        public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
                                            int[] offsetInWindow, int type) {
            return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                    offsetInWindow, type);
        }
    
        @Override
        public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
                                         @Nullable int[] offsetInWindow, int type, @NonNull int[] consumed) {
            mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                    offsetInWindow, type, consumed);
        }
    
        @Override
        public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
            return dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH);
        }
    
    
        @Override
        public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow, int type) {
            return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
        }
    
        @Override
        public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
            return mChildHelper.dispatchNestedFling(velocityX, velocityY, false);
        }
    
        @Override
        public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
            return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
        }
    
        @Override
        public int getNestedScrollAxes() {
            return ViewCompat.SCROLL_AXIS_VERTICAL;
        }
    
        @Override
        public void computeScroll() {
            if (mScroller.isFinished()) {
                return;
            }
    
            mScroller.computeScrollOffset();
            final int y = mScroller.getCurrY();
            int unconsumed = y - mLastScrollerY;
            mLastScrollerY = y;
    
            // Nested Scrolling Pre Pass
            mScrollConsumed[1] = 0;
            dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
                    ViewCompat.TYPE_NON_TOUCH);
            unconsumed -= mScrollConsumed[1];
    
    
            if (unconsumed != 0) {
                // Internal Scroll
                final int oldScrollY = getScrollY();
                overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, getScrollRange(),
                        0, 0, false);
                final int scrolledByMe = getScrollY() - oldScrollY;
                unconsumed -= scrolledByMe;
    
                // Nested Scrolling Post Pass
                mScrollConsumed[1] = 0;
                dispatchNestedScroll(0, 0, 0, unconsumed, mScrollOffset,
                        ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
                unconsumed -= mScrollConsumed[1];
            }
    
            if (unconsumed != 0) {
                abortAnimatedScroll();
            }
    
            if (!mScroller.isFinished()) {
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }
    
        // copied from NestedScrollView exacly as it looks, leaving overscroll related code, maybe future use
        private boolean overScrollByCompat(int deltaX, int deltaY,
                                           int scrollX, int scrollY,
                                           int scrollRangeX, int scrollRangeY,
                                           int maxOverScrollX, int maxOverScrollY,
                                           boolean isTouchEvent) {
            final int overScrollMode = getOverScrollMode();
            final boolean canScrollHorizontal =
                    computeHorizontalScrollRange() > computeHorizontalScrollExtent();
            final boolean canScrollVertical =
                    computeVerticalScrollRange() > computeVerticalScrollExtent();
            final boolean overScrollHorizontal = overScrollMode == View.OVER_SCROLL_ALWAYS
                    || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollHorizontal);
            final boolean overScrollVertical = overScrollMode == View.OVER_SCROLL_ALWAYS
                    || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollVertical);
    
            int newScrollX = scrollX + deltaX;
            if (!overScrollHorizontal) {
                maxOverScrollX = 0;
            }
    
            int newScrollY = scrollY + deltaY;
            if (!overScrollVertical) {
                maxOverScrollY = 0;
            }
    
            // Clamp values if at the limits and record
            final int left = -maxOverScrollX;
            final int right = maxOverScrollX + scrollRangeX;
            final int top = -maxOverScrollY;
            final int bottom = maxOverScrollY + scrollRangeY;
    
            boolean clampedX = false;
            if (newScrollX > right) {
                newScrollX = right;
                clampedX = true;
            } else if (newScrollX < left) {
                newScrollX = left;
                clampedX = true;
            }
    
            boolean clampedY = false;
            if (newScrollY > bottom) {
                newScrollY = bottom;
                clampedY = true;
            } else if (newScrollY < top) {
                newScrollY = top;
                clampedY = true;
            }
    
            if (clampedY && !hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
                mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange());
            }
    
            onOverScrolled(newScrollX, newScrollY, clampedX, clampedY);
    
            return clampedX || clampedY;
        }
    }
    

    edit: this class have a huge flaw! this View consumes all scroll events dispatching them to Toolbar, Coordinator or it own BUT never ever for nested-scrolling div/view inside HTML content. e.g. if you have own implementation of drop down list (or any self-scrolling view) it wont be scrolled by finger, all events consumed by native views... I've ended up throwing this class away and all nested-scrolling features related to f**king WebView