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
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容