Android 嵌套滑动的研究篇一

0. 参考资料

来自鸿洋的博客的例子,非常感谢:
http://blog.csdn.net/lmj623565791/article/details/43649913

整体代码:
https://github.com/zhaoyubetter/KotlinAndroidDemo/blob/master/widget/src/main/java/test/com/widget/nested/StickyNavVerticalLayout2.kt

1. 嵌套滑动场合

如上面的例子,当内层的view,如:list,滑动到顶部时,即 firstChild.getTop = 0 的,我们需要将list的事件,转而给外层(怎么给呢?),让外层,去消耗,这点稍有些麻烦(鸿洋的博客中有解决);在Android的事件分发中,如果找到了target了,就一直会把后续的事件源源不断的给target;
而此时,一般我们需要抬起手指,然后重新下拉,这个时候,外层就收到了事件了;

那可以实现嵌套滑动吗?答案是可以的,我们可以用 nestedScrolling 的api来做,这块,我还暂未了解。所有这里,采用原始的事件分发方案来解决这个问题;

2. 准备开始吧

这里我简化了一下代码,仅供参考;

2.1 简化了布局文件如下

<test.com.widget.nested.StickyNavVerticalLayout2 
       xmlns:android="http://schemas.android.com/apk/res/android"
                   android:layout_width="match_parent"
                   android:layout_height="match_parent"
                   android:orientation="vertical">

    <!-- 头部 -->
    <RelativeLayout
        android:id="@+id/id_stickynavlayout_topview"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#4400ff00">
        <TextView
            android:layout_width="match_parent"
            android:layout_height="256dp"
            android:gravity="center"
            android:text="嵌套滑动"
            android:textSize="30sp"
            android:textStyle="bold"/>
    </RelativeLayout>

    <!-- 假的悬浮头 -->
    <TextView
        android:id="@+id/id_stickynavlayout_indicator"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:background="#ffffffff"
        android:gravity="center"
        android:text="悬浮头"/>

    <!-- 嵌套的 scrollView  -->
    <ScrollView
        android:id="@+id/id_stickynavlayout_scrollview"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <LinearLayout
            android:divider="?android:attr/listDivider"
            android:showDividers="middle"
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="100dp"
                android:text="text1"/>
            <!-- 更多内容 -->
        </LinearLayout>
    </ScrollView>

</test.com.widget.nested.StickyNavVerticalLayout2>

3. 过程实现

一步一步来实现;

3.1 先实现界面布局

创建StickyNavVerticalLayout继承自LinearyLayout,并添加相应的一些代码,注释在代码里:

class StickyNavVerticalLayout2(context: Context, attrs: AttributeSet?, defAttrStyle: Int) : LinearLayout(context, attrs, defAttrStyle) {
    constructor(context: Context) : this(context, null)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    // --- 内部 View 相关成员变量
    private var top: View? = null
    private var nav: View? = null
    private var scrollView: View? = null
    private var topHeight: Int? = 0     // top的高度

    init {
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        top = findViewById(R.id.id_stickynavlayout_topview)
        nav = findViewById(R.id.id_stickynavlayout_indicator)
        scrollView = findViewById(R.id.id_stickynavlayout_scrollview)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        scrollView?.layoutParams?.height = measuredHeight.minus(nav?.measuredHeight ?: 0)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        topHeight = top?.measuredHeight?: 0
    }
}

现在的界面效果:

布局

底部的scrollView可以滑动,但是不能滑动到底部;

3.2 添加手势处理逻辑

实现 onInterceptTouchEvent, onTouchEvent, 注意,在这里,我们这2个方法,都返回true,表示事件由自己处理,不传给子view(scrollView 收不到事件)

   // --- 事件操作相关的成员变量
    private var lastY: Int = 0
    private var isDrag = false      // 是否拖拽

    private var scroller: Scroller = Scroller(getContext())
    private var velocityTracker: VelocityTracker? = null
    private var touchSlop: Int = 0
    private var maxFlingVelocity: Int = 0
    private var minFlingVelocity: Int = 0


    init {
        val config = ViewConfiguration.get(context)
        touchSlop = config.scaledTouchSlop
        maxFlingVelocity = config.scaledMaximumFlingVelocity
        minFlingVelocity = config.scaledMinimumFlingVelocity
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?.let {
            val y = it.y
            initVelocityTracker()
            velocityTracker?.let { it.addMovement(event) }     // 添加运动轨迹

            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    lastY = y.toInt()
                }
                MotionEvent.ACTION_MOVE -> {
                    val dy = y - lastY
                    if (!isDrag && Math.abs(dy) > touchSlop) {
                        isDrag = true
                    }
                    if (isDrag) {
                        scrollBy(0, -dy.toInt())    // 反向取反
                    }
                    lastY = y.toInt()
                }

                MotionEvent.ACTION_UP -> {          // 抬起,运动轨迹判断,是否fling
                    isDrag = false
                    velocityTracker?.let {
                        it.computeCurrentVelocity(1000, maxFlingVelocity?.toFloat())
                        if (Math.abs(it.yVelocity) > minFlingVelocity) {
                            fling(-it.yVelocity.toInt())
                        }
                    }
                    releaseVelocity()
                }

                MotionEvent.ACTION_CANCEL -> {
                    isDrag = false
                    releaseVelocity()
                }
            }
        }
        return true    
        //return super.onTouchEvent(event)
    }

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        return true
        //return super.onInterceptTouchEvent(ev)
    }

    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            scrollTo(0, scroller.currY)
            invalidate()
        }
    }

  /**
     * 边界处理
     */
    override fun scrollTo(x: Int, y: Int) {
        var tmpY = y
        if (y < 0) tmpY = 0
        if (y > topHeight) tmpY = topHeight
        super.scrollTo(x, tmpY)
    }

    private inline fun fling(velocityY: Int) {
        scroller.let {
            it.fling(0, scrollY, 0, velocityY, 0, 0, 0, topHeight)     // 滑翔
            invalidate()
        }
    }

    private inline fun initVelocityTracker() {
        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain()
        }
    }

    private inline fun releaseVelocity() {
        velocityTracker?.let {
            it.recycle()
            velocityTracker = null
        }
    }

大部分说明 在鸿洋的 博客里写的很详细了,这里就不再叙述了;
主要目的是,实现 StickyNavVerticalLayout2 的整体的滑动,滑翔等;
可以看到 scrollview不能单独滑动
实现效果为:

整体拦截事件效果

3.3 拦截事件的处理

去掉 onTouchEvent 的 return true;
重写onInterceptTouchEvent方法,让其在特定的情况下拦截事件,需要拦截分为2种情况:

  1. topView 可见时拦截;
  2. topview不可见,并且 内部的 scrollView 在顶部,并且还在下拉的状态下,进行拦截;

我们这里使用ViewCompat来进行View是否还可以继续滚动的判断;我们来看代码:

// 先添加成员变量,记录 top 的可见状态
private var topHide = false

override fun scrollTo(x: Int, y: Int) {
        var tmpY = y
        if (y < 0) tmpY = 0
        if (y > topHeight) tmpY = topHeight
        super.scrollTo(x, tmpY)
        topHide = scrollY == topHeight  // 更新 topHide
 }

override fun onTouchEvent(event: MotionEvent?): Boolean {
   .....省略代码.....
   // return true
   return super.onTouchEvent(event)
}

    /**
     * 拦截判断
     */
  override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        val y = ev.y
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> lastY = y.toInt()
            MotionEvent.ACTION_MOVE -> {        // 重点
                val dy = y - lastY
                if (Math.abs(dy) > touchSlop) {
                    // topView 可见 || (topView不可见 && scrollView不能再下拉 && 继续下拉)
                    if (!topHide || (topHide && !ViewCompat.canScrollVertically(scrollView, -1) && dy > 0)) {
                        lastY = y.toInt()
                        isDrag = true
                        initVelocityTracker()
                        velocityTracker?.let {
                            it.addMovement(ev)
                        }
                        return true
                    }
                }
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                isDrag = false
                releaseVelocity()
            }
        }

        return super.onInterceptTouchEvent(ev)
    }

到现在,已经完成了基本的需求了,但是嵌套滑动还是不行,不连贯,没有那种一口气 硬扯到底 的 感觉,需要放开,再拉 ;
效果图,如下:

实现效果

这个时候,就回到了,开头留下的问题了,如何实现一拉到底中间整个过程没有间断;我们需要使用 dispatchTouchEvent 这个方法了;

3.4 重新 dispatchTouchEvent 嵌套的滑动实现

上面的问题,在于,当事件被 子 view 接收后,后续的事件,都会跑到子view;但事件的传递,都是从父到子的过程,事件的传递会经过父的dispatch,通过这个方法,将事件在父与子View中根据条件来传递,从而实现嵌套滑动;

private var isInControl = false     // 是否已dispatch

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> lastY = ev.y.toInt()
            MotionEvent.ACTION_MOVE -> {    // 进行判断,是否重发事件
                val dy = ev.y - lastY
                // 头不可见,继续下拉,重发事件
                if (topHide && !ViewCompat.canScrollVertically(scrollView, -1) && dy > 0 && !isInControl) {
                    isInControl = true
                    ev.action = MotionEvent.ACTION_CANCEL
                    val ev2 = MotionEvent.obtain(ev)
                    dispatchTouchEvent(ev)
                    ev2.action = MotionEvent.ACTION_DOWN
                    return dispatchTouchEvent(ev2)
                }
            }
        }

        return super.dispatchTouchEvent(ev)
    }

我们需要在 onTouchEvent方法加入以下代码片段,即:在边界时,将MOVE事件转换成 DOWN事件,重新进行分发;

=== > onTouchEvent  方法中修改

MotionEvent.ACTION_MOVE -> {
     val dy = y - lastY
     if (!isDrag && Math.abs(dy) > touchSlop) {
             isDrag = true
     }
     if (isDrag) {
             scrollBy(0, -dy.toInt())    // 反向取反
     }
     // 如果滑到顶了,将事件转换成点击事情,发送
     if (scrollY == topHeight) {
              event.action = MotionEvent.ACTION_DOWN
              dispatchTouchEvent(event)
              isInControl = false
      }
     lastY = y.toInt()
 }

scroller的优化,在onInterceptTouchEvent() 与 onTouchEvent中分别 加入:

 override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        val y = ev.y
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> lastY = y.toInt()
            MotionEvent.ACTION_MOVE -> {        // 重点
                // 惯性未结束,拦截事件
                if(!scroller.isFinished) {
                    return true
                }
.......
.......

  // onTouchEvent
  override fun onTouchEvent(event: MotionEvent?): Boolean {
           // .... 省略代码
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    lastY = y.toInt()
                    if(!scroller.isFinished) {       // 未结束时,结束scroller
                        scroller.abortAnimation()
                        return true
                    }
                }
        
               // ACTION_CANCEL时时,取消scroller
                MotionEvent.ACTION_CANCEL -> {  
                    isDrag = false
                    if(!scroller.isFinished) {
                        scroller.abortAnimation()
                    }
                    releaseVelocity()
                }

最终效果如下:

最终效果

水平滑动(加深印象)

效果如下:

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

推荐阅读更多精彩内容