android 嵌套滑动解决方案

android开发中常常会有这样的现象 顶部是图片banner 下面是tablayout + viewpager
viewpager 由 fragment 适配 fragment 内部是recyclerview

我们想要在滑动时 先把banner 划出屏幕 然后tablayout 吸顶 然后在滑动recyclerview 即嵌套滑动

效果图.png

想要实现这样的效果 我们有两种实现方式

1 传统解决方案

android 事件分发机制
activity 获取到事件 分发给 decorview 在分发给xml 根布局的viewgroup 事件 在一层一层往下分发
具体方法为 viewgrooup.dispatchTouchEvent -> viewgroup.onInterceptTouchEvent -> view.dispatchTouchEvent -> view.onTouchEvent -> viewgroup.onTouchEvent
在down 事件中 获取到targetview 然后在move 事件中会直接走targetview 的ontouch 的方法

传统解决方案又分为 内部拦截法 和外部拦截法

内部拦截法

让子view来控制事件的分发 逻辑如下
父控件重写 onInterceptTouchEvent 方法 在down事件中返回false 其他事件中 返回为true
子控件重写 onTouchEvent 方法 因为子view 可以拿到 down 事件 在这里可以根据自己是否需要处理事件
调用getParent.requestDisallowInterceptTouchEvent(true) 来阻止父控件在后续的move事件中拦截事件

外部拦截法

让父控件来控制事件的分发 逻辑如下
父控件重写 onInterceptTouchEvent 方法 根据自身的需求 来返回true 或者 false

总结 外部拦截法 只需要重写父控件 内部拦截法 需要重写 父控件以及子控件
这里我们拿外部拦截法为例

外部拦截法的实现

根据业务场景需要一个自上而下的排列布局 所以选择继承自 linearlayout 方向为 vertical

内部一共有三个view
headview
tabview
viewpager

初始化三个变量

override fun onFinishInflate() {
        super.onFinishInflate()
        mViewPager = findViewById(R.id.view_pager)
        mHeaderView = findViewById(R.id.tv_head)
        mTabView = findViewById(R.id.tab_layout)
    }

拿到 headview 的高度

  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 获取headerview 的高度
        mHeadTopHeight = mHeaderView.measuredHeight
    }

我们为了让headview划出屏幕之后 让tabview 吸顶 需要设置底部viewpager的高度= 布局总高度 - tabview 的高度

   override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // viewpager 修改后的高度  =  总高度 - 导航栏的高度
        var layoutParams = mViewPager.layoutParams
        layoutParams.height = measuredHeight - mTabView.measuredHeight
        mViewPager.layoutParams = layoutParams
    }

重写onInterceptTouchEvent 方法
在down 事件中 记录 最后一次点击的 y 值
在move 事件中就可以获取到 y 的偏移量 向上滑为 正值 向下滑为负值
这里的正负如何区分 先了解下android 坐标系 向下为正 向上位负 向上滑动 即 从下往上 及 从大的值往小的值改变 初始y值 减去 滑动之后的y值 结果为正

 override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        var y = ev.getY().toInt()
        when(ev.action){
            MotionEvent.ACTION_DOWN -> mLastY = y
            MotionEvent.ACTION_MOVE -> {
                var dy = mLastY - y
                // 如果向上滑  并且 headview 还没有完全划出屏幕则需要拦截事件 自己处理
                if(dy > 0 && scrollY < mHeadTopHeight){
                    return true
                // 如果向下滑动   并且headview 还没有完全展示在屏幕内则需要拦截事件 自己处理
                }else if(dy < 0 && scrollY  > 0 && scrollY <=mHeadTopHeight){
                    return true
                }
            }
        }
        return super.onInterceptTouchEvent(ev)
    }

重写onTouchEvent 事件 记录偏移量 并调用 scrollby 方法

    override fun onTouchEvent(event: MotionEvent): Boolean {
        var action = event.action
        var y = event.getY()
        when(action){
            MotionEvent.ACTION_DOWN -> mLastY = y.toInt()
            MotionEvent.ACTION_MOVE -> {
                var dy = mLastY - y
                scrollBy(0 , dy.toInt())
                mLastY = y.toInt()
            }
        }
        return super.onTouchEvent(event)
    }

// 重写scrollto 方法 因为 scrollby最终会调用scrollto 防止父控件划出屏幕外的距离最多是headview 的高度

    override fun scrollTo(x: Int, y: Int) {
        var needy = y
        if(y < 0){
            needy = 0
        }
        if(y > mHeadTopHeight){
            needy = mHeadTopHeight
        }
        super.scrollTo(x, needy)
    }

完整代码

package com.bhb.netstedscrollview.demo4.layout

import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.widget.LinearLayout
import androidx.viewpager.widget.ViewPager
import com.bhb.netstedscrollview.R

/**
 *  create by BHB on 5/23/22
 */
class TraditionalNestedLayout : LinearLayout {


    lateinit var mHeaderView : View
    lateinit var mTabView : View
    lateinit var mViewPager : ViewPager
    var mHeadTopHeight = 0
    var mLastY = 0

    constructor(context: Context , attr:AttributeSet):super(context , attr){

    }

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        var y = ev.getY().toInt()
        when(ev.action){
            MotionEvent.ACTION_DOWN -> mLastY = y
            MotionEvent.ACTION_MOVE -> {
                var dy = mLastY - y
                // 如果向上滑  并且 headview 还没有完全划出屏幕则需要拦截事件 自己处理
                if(dy > 0 && scrollY < mHeadTopHeight){
                    return true
                // 如果向下滑动   并且headview 还没有完全展示在屏幕内则需要拦截事件 自己处理
                }else if(dy < 0 && scrollY  > 0 && scrollY <=mHeadTopHeight){
                    return true
                }
            }
        }
        return super.onInterceptTouchEvent(ev)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        var action = event.action
        var y = event.getY()
        when(action){
            MotionEvent.ACTION_DOWN -> mLastY = y.toInt()
            MotionEvent.ACTION_MOVE -> {
                var dy = mLastY - y
                scrollBy(0 , dy.toInt())
                mLastY = y.toInt()
            }
        }
        return super.onTouchEvent(event)
    }

    // 重写scrollto 方法 因为 scrollby最终会调用scrollto
    override fun scrollTo(x: Int, y: Int) {
        var needy = y
        if(y < 0){
            needy = 0
        }
        if(y > mHeadTopHeight){
            needy = mHeadTopHeight
        }
        super.scrollTo(x, needy)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // viewpager 修改后的高度  =  总高度 - 导航栏的高度
        var layoutParams = mViewPager.layoutParams
        layoutParams.height = measuredHeight - mTabView.measuredHeight
        mViewPager.layoutParams = layoutParams
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 获取headerview 的高度
        mHeadTopHeight = mHeaderView.measuredHeight
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        mViewPager = findViewById(R.id.view_pager)
        mHeaderView = findViewById(R.id.tv_head)
        mTabView = findViewById(R.id.tab_layout)
    }

}

实现效果


传统效果图

效果实现了 但是这里 滑动体验并不好 因为我们的事件是中断的 比如开始我们向上滑动很大的距离 但是实际只能把banner 划出屏幕外 想要继续滑动recyclerview 必须要松开手指 再次上滑
这是由于事件分发机制 导致的

想要嵌套滑动体验更加 我们需要使用下面的 nestedScroll 方案

nestedScroll 方案

根布局使用系统提供的 NestedScrollView
这是因为 NestedScrolling提供了一套父 View 和子 View 滑动交互机制。要完成这样的交互,父 View 需要实现 NestedScrollingParent 接口,而子 View 需要实现 NestedScrollingChild 接口

分析 NestedScrollingParent 和 NestedScrollingChild 的方法调用顺序

这里以 NestedScrollView 和 RecyclerView 的源码为例
NestedScrolling 并没有改变原有的事件分发机制 所以我们从 子view 的ontouch 事件开始看

image.png

子view down 事件 触发
image.png

然后调用 NestedScrollingChildHelper 的 startNestedScroll 方法
NestedScrollingChildHelper 在 初始化时 传入子view

image.png

NestedScrollingChildHelper 的startNestedScroll 会先判断 子view 是否开始了 嵌套滑动
然后在去寻找父view 判断父view 是否支持嵌套滑动ViewParentCompat.onStartNestedScroll
在接着调用 ViewParentCompat.onNestedScrollAccepted

image.png

ViewParentCompat.onNestedScrollAccepted 的方法实际会走到 NestedScrollingParent.onNestedScrollAccepted 方法中


image.png

在这里 scrollparent 实际是 nestedscrollview 那会就会调用它的onNestedScrollAccepted方法


image.png

接着调用其 startNestedScroll 方法 并写死的方向为竖向
image.png

这里 若是 NestedScrollView 本身也被另外一层嵌套滑动包围 那么该事件 还会有 childHelper 向上传递

回到Recyclerview 的 ontouch move 事件中

image.png

这里会调用 childHelper 的 dispatchNestedPreScroll 方法中
image.png

然后会调用父view 的 onNestedPreScroll 方法
父view 可以根据 dx 和 dy 优先滑动 然后修改 int[] consumed 的值
父view 修改完之后 子view 拿到被修改之后的 consumed 数组 调用 scrollByInternal 方法

image.png

子view 处理完了之后 会再次调用 dispatchNestedScroll 方法 将剩余未处理的 滑动距离 传递给 父view

回到子view 的 up 和 cancel 事件 最终都会调用 stopNestedScroll 方法


image.png

然后调用childHelper 的stopNestedScroll


image.png

最终会调用父view 的 onStopNestedScroll 在经


image.png

自定义实现 嵌套滑动的父布局 继承自 LinearLayout 实现 NestedScrollingParent2

tablayout 吸顶的逻辑与 传统方式一致
重写 onStartNestedScroll 方法 判断是否是竖向滑动

 /**
     * 判断父view 是否需要处理嵌套滑动
     * 只处理 竖向的事件
     */
    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
        return (axes and  ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

重写 onNestedScrollAccepted 方法 调用parentHelper

 // 当onStartNestedScroll返回为true 时   调用该方法
    override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
    }

重写 onNestedPreScroll 方法 在这里 让父view 先滑动 然后再将已经处理的 距离赋值给 consumed 传给子view

 /**
     * 在嵌套滑动的子View未滑动之前,判断父view是否优先与子view处理(也就是父view可以先消耗,然后给子view消耗)
     *
     * @param target   具体嵌套滑动的那个子类
     * @param dx       水平方向嵌套滑动的子View想要变化的距离
     * @param dy       垂直方向嵌套滑动的子View想要变化的距离 dy<0向下滑动 dy>0 向上滑动
     * @param consumed 这个参数要我们在实现这个函数的时候指定,回头告诉子View当前父View消耗的距离
     *                 consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离 好让子view做出相应的调整
     * @param type     滑动类型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手势滑动
     */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
       var hideTop = dy > 0 && scrollY < mTopViewHeight
        var showTop = dy < 0 && !target.canScrollVertically(-1)
        if(hideTop || showTop){
            scrollBy(0 , dy)
            consumed[1] = dy
        }
    }

完整代码

package com.bhb.netstedscrollview.demo4.layout.my

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.NestedScrollingParent2
import androidx.core.view.NestedScrollingParentHelper
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
import androidx.viewpager.widget.ViewPager
import com.bhb.netstedscrollview.R
import com.google.android.material.tabs.TabLayout

/**
 *  create by BHB on 5/23/22
 */
class MyNestedScrollingParent2Layout :LinearLayout , NestedScrollingParent2  {


    lateinit var mNestedScrollingParentHelper : NestedScrollingParentHelper
    lateinit var tabLayout: TabLayout
    lateinit var  viewPager : ViewPager
    lateinit var  tvHead : TextView

    var mTopViewHeight = 0


    override fun onFinishInflate() {
        super.onFinishInflate()
        tabLayout = findViewById(R.id.tab_layout)
        viewPager = findViewById(R.id.view_pager)
        tvHead = findViewById(R.id.tv_head)
    }

    constructor(context: Context , attr : AttributeSet):super(context , attr){
        mNestedScrollingParentHelper = NestedScrollingParentHelper(this)
    }

    /**
     * 判断父view 是否需要处理嵌套滑动
     * 只处理 竖向的事件
     */
    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
        return (axes and  ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

    // 当父view onStartNestedScroll返回为true 时   调用该方法
    override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
    }

    /**
     * 在嵌套滑动的子View未滑动之前,判断父view是否优先与子view处理(也就是父view可以先消耗,然后给子view消耗)
     *
     * @param target   具体嵌套滑动的那个子类
     * @param dx       水平方向嵌套滑动的子View想要变化的距离
     * @param dy       垂直方向嵌套滑动的子View想要变化的距离 dy<0向下滑动 dy>0 向上滑动
     * @param consumed 这个参数要我们在实现这个函数的时候指定,回头告诉子View当前父View消耗的距离
     *                 consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离 好让子view做出相应的调整
     * @param type     滑动类型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手势滑动
     */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
       var hideTop = dy > 0 && scrollY < mTopViewHeight
        var showTop = dy < 0 && !target.canScrollVertically(-1)
        if(hideTop || showTop){
            scrollBy(0 , dy)
            consumed[1] = dy
        }
    }

    /**
     * 嵌套滑动的子View在滑动之后,判断父view是否继续处理(也就是父消耗一定距离后,子再消耗,最后判断父消耗不)
     *
     * @param target       具体嵌套滑动的那个子类
     * @param dxConsumed   水平方向嵌套滑动的子View滑动的距离(消耗的距离)
     * @param dyConsumed   垂直方向嵌套滑动的子View滑动的距离(消耗的距离)
     * @param dxUnconsumed 水平方向嵌套滑动的子View未滑动的距离(未消耗的距离)
     * @param dyUnconsumed 垂直方向嵌套滑动的子View未滑动的距离(未消耗的距离)
     * @param type         滑动类型,ViewCompat.TYPE_NON_TOUCH fling 效果ViewCompat.TYPE_TOUCH 手势滑动
     */
    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int
    ) {
         // 当子控件处理完毕后 再次交给父控件进行处理
        if(dyUnconsumed < 0){
            // 表示已经下滑到头
            scrollBy(0 , dyUnconsumed)
        }
    }

    /**
     *  嵌套滑动结束
     */
    override fun onStopNestedScroll(target: View, type: Int) {
        mNestedScrollingParentHelper.onStopNestedScroll(target, type)
    }

    /**
     *   在子类处理惯性事件前 判断父控件是否处理
     */
    override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
        return false
    }

    /**
     *  惯性事件
     */
    override fun onNestedFling(
        target: View,
        velocityX: Float,
        velocityY: Float,
        consumed: Boolean
    ): Boolean {
       return false
    }

    override fun getNestedScrollAxes(): Int {
        return mNestedScrollingParentHelper.nestedScrollAxes
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        var layoutParams = viewPager.layoutParams
        layoutParams.height = measuredHeight - tabLayout.height
        viewPager.layoutParams = layoutParams
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mTopViewHeight = tvHead.measuredHeight
    }

    override fun scrollTo(x: Int, y: Int) {
        var needY = y
        if( y < 0 ){
            needY = 0
        }
        if(y > mTopViewHeight){
            needY = mTopViewHeight
        }
        super.scrollTo(x, needY)
    }

}

实现效果


最终效果.gif

你可能感兴趣的:(android 嵌套滑动解决方案)