Search code examples
androidwebviewandroid-collapsingtoolbarlayoutandroid-nestedscrollviewcoordinator-layout

WebView doesn't scroll in NestedScrollView with CollapsibleToolBarLayout


I currently have a layout setup like this:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fitsSystemWindows="true"
        android:elevation="4dp">
        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar"
            android:layout_width="match_parent"
            android:layout_height="250dp"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">
            <ImageView
                android:id="@+id/backdrop"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:fitsSystemWindows="true"
                android:scaleType="centerCrop"
                android:src="@drawable/android_icon"
                app:layout_collapseMode="parallax" />
            <androidx.appcompat.widget.Toolbar
                android:id="@+id/generic_toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:fitsSystemWindows="true"
                android:background="@null"
                app:layout_collapseMode="pin"/>
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>
    <androidx.core.widget.NestedScrollView
        android:id="@+id/primary_nestedScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
        <WebView
            android:id="@+id/primary_webview"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

While this works fine mostly, there is a issue with the scrolling of the webview when I want to make sure its content fills the viewport.

If I add the android:fillViewport="true" property to the NestedScrollView the viewport fills up properly and the collapsing toolbar still collapses, but the webview itself cannot be scrolled anymore.

I've demonstrated this in a little demo project video: https://i.imgur.com/EJevXuz.mp4

(EDIT: It seems the recording has some flickering artifacts. This is a recording issue and unrelated to the question)

As you can see scrolling works fine when fillViewPort is set to false. Although as expected, the content (which has a dark grey background) doesn't fill the available space if it's short/small.

When the fillViewPort value is set to true on the NestedScrollView the content('s background) does fill the available space, but any scrolling on larger content seems to be disabled (the WebView is "stuck" at its top scroll position) even though the scrollbar in the webview indicates more content is available.

I've tried many things to get this working again (including several NestedWebView implementations found on the web) but to no avail so far.

How can I get WebView with the fillViewPort option scrolling properly again in this layout setup?


Solution

  • I finally found a NestedWebView implementation that worked for my situation.

    This implementation removes the neccesity of NestedScrollView altogether, allowing the webview to automatically adjust its viewport based on wrap_content/match_parent layout values, rather than the viewport handling of the scrollview.

    It was a relatively recently added implementation by snachmsm as an answer to a similar question :

    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;
        }
    }