【原理篇】WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X5 webview

简介: 【原理篇】WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X5 webview

上一篇文章 【使用篇】WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X5 webview

已经讲解了如何实现嵌套滑动,这篇文章,让我们一起来看他的实现原理。废话不多说,开始进入正文。


前言


讲解之前,先简单说一下嵌套滑动的一些概念。(熟悉这个的哥们可以直接跳过这个)


说到嵌套滑动,大家应该都不陌生。他是 Google 在 5.0 之后推出来的 NestedScroll 机制。


可能初学者会有这样的疑问?想比较于传统的事件分发机制,NetstedScroll 机制有什么优点。


在传统的事件分发机制 中,一旦某个 View 或者 ViewGroup 消费了事件,就很难将事件交给父 View 进行共同处理。而 NestedScrolling 机制很好地帮助我们解决了这一问题。我们只需要按照规范实现相应的接口即可,子 View 实现 NestedScrollingChild,父 View 实现 NestedScrollingParent ,通过 NestedScrollingChildHelper 或者 NestedScrollingParentHelper 完成交互。


b7a1f6f49c96f7adeaa96a45d4570bd0_ea967568f57ca28f965d800a4ec5a91a.png


如果对于 NestedScrolling 机制不了解的,可以看我几年前写的这篇文章。

NestedScrolling 机制深入解析


他结合 CoordinatorLayout 可以实现很多炫酷的效果,比如吸顶效果等。


有兴趣的话可以看这些文章。


使用CoordinatorLayout打造各种炫酷的效果

自定义Behavior —— 仿知乎,FloatActionButton隐藏与展示

NestedScrolling 机制深入解析

一步步带你读懂 CoordinatorLayout 源码

自定义 Behavior -仿新浪微博发现页的实现

ViewPager,ScrollView 嵌套ViewPager滑动冲突解决

自定义 behavior - 完美仿 QQ 浏览器首页,美团商家详情页


原理实现


废话不多说,今天,让我们一起来看看 WebView 怎样实现嵌套滑动。



原理简述


我们知道,嵌套滑动目前主要有几个接口 NestedScrollingChild,NestedScrollingParent 。


对于一个 ACTION_MOVE 动作


  • scrolling child 在滑动之前,会通过 NestedScrollingChildHelper 查找是否有响应的 scrolling parent,如果有的话,会先询问scrolling parent 是否需要先于scrolling child 滑动,如果需要的话,scrolling parent 进行相应的滑动,并消费一定的距离;
  • 接着scrolling child 进行相应的滑动,并消耗一定的距离值 dx,dy
  • scrolling child 滑动完之后,询问scrolling parent 是否还需要继续进行滑动,需要的话,进行相应的处理。
  • 滑动结束之后,Scrolling child 会停止滑动,并通过 NestedScrollingChildHelper 通知相应的 Scrolling Parent 停止滑动。
  • 手指抬起的时候(Action_up) 的时候,根据滑动速度,计算是否相应 fling


而我们的 WebView 如果要实现嵌套滑动,那就可以借助这套机制。


实现


第一步,实现 NestedScroolChild3 接口,并重写相应的方法


public class NestedWebView extends WebView implements NestedScrollingChild3 {
    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);
    }
    // 省略
}

第二步:


  • 在 ACTION_DOWN 的时候,先调用 startNestedScroll 方法,告诉 NestedScrollParent,说我要滑动了
  • 接着,在 ACTION_MOVE 的时候,调用 dispatchNestedPreScroll 方法,让 NestedScrollParent 有机会可以提前滑动,接着调用自身的 dispatchNestedScroll 方法,进行活动
   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;

第三步:在 ACTION_UP 的时候,计算一下垂直方向的滑动速度,并进行分发


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;

同时重写 computeScroll 方法,处理惯性滑动


// 在更新 mScrollX 和 mScrollY 的时候会调用
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();
    }
    // 判断是否滑动完成,没有完成的话,继续滑动 mScroller
    if (!mScroller.isFinished()) {
        ViewCompat.postInvalidateOnAnimation(this);
    }
}

最后,为了确保 onTouchEvent 能够收到触摸事件,我们在 onInterceptTouchEvent 中进行拦截


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

处理完之后,我们的 webview 就实现了 NestedScrol 机制,可以进行嵌套滑动了。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZLoWwHcf-1663672759560)(https://raw.githubusercontent.com/gdutxiaoxu/blog_image/master/22/04/webview%20%E5%B5%8C%E5%A5%97%E6%BB%91%E5%8A%A8.gif)]


X5 webView 兼容


当我将代码搬到 x5 webview 的时候,这时候进行滑动,发现无法联动了。


class NestedWebView extends com.tencent.smtt.sdk.WebView implements NestedScrollingChild3


原因分析

这是什么原因呢?


我们点进去 X5 webView 里面的代码,发现 webView 是继承 FrameLayout,而不是继承系统 WebView。


3c20cd1d59970c38d1ef4a2d42ef091e_1cec5d647fa7fa6029a3b9930153de22.png


因此我们直接 extends com.tencent.smtt.sdk.WebView,对触摸事件进行拦截,实际上是对 FrameLayout 进行拦截处理,而不是对里面的 WebView 进行拦截处理,那肯定达不到嵌套滑动。


解决方案

我们先来看一下 X5 webView 的 View Tree 结构,因为 X5 webView 代码是混淆的,我们想要通过代码直接看出他的 View Tree,是不太方便的。


于是,我们可以通过代码,将 x5 webView viewTree 结构打印出来


webView = view.findViewById<WebView>(R.id.webview)
val childCount = webView.childCount
Log.i(TAG, "onViewCreated: webView  is $webView, childCount is $childCount")
for (i in 0 until childCount) {
    Log.i(TAG, "x5 webView: childView[$i]  is ${webView.getChildAt(i)}")
}

运行以上代码,得到以下结果


e924515f06a9b6ec76e48b3483a5aa0e_03ea42d4253ce98cc09c7e4e5f9f632c.png


可以看到 X5 WebView 应该就是在 WebView 的基础之上包了一层 FrameLayout。


那我们对没有办法拿到里面的 TencentWebViewProxy$InnerWebView 对象,其实是有的。他在里面有一个 getView 的方法。


拿到这个对象之后,我们有办法进行拦截处理嘛,像 onTouchEvent, onInterceptTouchEvent 方法?


我们在官方文档中 X5 webview 常见问题 找到这样的描述


db5e62f3790b664f324d3feae5c93177_77daa20314c115c92f3c47bb6380d10a.png


3.10 如何重写TBS WebView 的屏幕事件(例如 overScrollBy)

需 setWebViewCallbackClient 和 setWebViewClientExtension 参考代码示例 http://res.imtt.qq.com/tbs/BrowserActivity.zip


通过代码跟踪&调试,我们发现了 WebViewCallBackClient 的接口


02765af76cc7e714f38a8d89e918f8a1_9280fc550b4b70aa4adc4cc9218d0d04.png


当 X5 里面的 webview 进行滑动的时候,会调用相应的方法。那么,我们这时候就可以依样画葫芦,将上面 NestedWebView 的代码逻辑搬下来。


重写 onTouchEvent, onInterceptTouchEvent, computeScroll 这几个关键方法。


这样就实现了嵌套滑动。


具体的代码可以见 nestedwebview


总结


  1. 借助 NestedScrool 机制,要实现嵌套滑动其实还是蛮简单的,基本按照模板代码魔改一下就好了,要学会举一反三。
  2. 如果要实现一些自定义的效果,那么我们可以通过 Behavior 来实现,具体的可以参照 自定义 behavior - 完美仿 QQ 浏览器首页,美团商家详情页


参考博客


NestedWebView working properly with ScrollingViewBehavior

X5 WebView 官网


源码地址


nestedwebview, 可以帮忙给个 star 哦。


相关文章
|
Android开发
【原理篇】WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X6 webview(二)
【原理篇】WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X6 webview
|
Android开发
【原理篇】WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X6 webview(一)
【原理篇】WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X6 webview
|
Android开发
WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X5 webview
WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X5 webview
194 0
WebView 实现嵌套滑动,丝滑般实现吸顶效果,完美兼容 X5 webview
|
Android开发
NestedScrollView嵌套WebView导致底部有空白
NestedScrollView嵌套WebView导致底部有空白
558 0
|
API Android开发 数据安全/隐私保护
解决android webview 加载http url 失败 net::ERR_CLEARTEXT_NOT_PERMITTED 错误
解决android webview 加载http url 失败 net::ERR_CLEARTEXT_NOT_PERMITTED 错误
988 0
|
4月前
|
Web App开发 JavaScript 前端开发
Android端使用WebView注入一段js代码实现js调用android
Android端使用WebView注入一段js代码实现js调用android
122 0
|
5月前
|
Web App开发 移动开发 前端开发
52. 【Android教程】网页视图:WebView
52. 【Android教程】网页视图:WebView
93 1
|
Android开发 iOS开发 UED
Android webView 实现阻尼回弹效果
iOS webView默认滑动到顶部或者底部的时候,还可以继续通过手指拉扯滑动,松手后回弹;而Android webView默认是不行的,要实现跟iOS一样的效果,就需要自定义webView。
582 0
|
6月前
|
JavaScript 前端开发 Android开发
android开发,使用kotlin学习WebView(详细)
android开发,使用kotlin学习WebView(详细)
510 0
|
定位技术 Android开发
[√]Android webview的url scheme
[√]Android webview的url scheme
535 0