LOFTER新版个人主页嵌套滑动实现方案

前言

NestedScrolling是Android推出的一套嵌套滑动机制,能够让父View和子View在滑动时相互协调配合可以实现连贯的嵌套滑动,它基于原有的事件分发机制上为ViewGroup和View增加处理滑动的方法调用。本文从基本NestedScrolling机制的基本原理出发,详细介绍了如何利用NestScrolling机制实现LOFTER新版个人主页嵌套滑动效果。

1.效果预览

LOFTER 7.1.0版本需求,个人主页要实现以下嵌套滑动效果:

1659519074804034 (1).gif

2.技术方案选型

需求上需要实现父布局优先滑动、滑动到某个时机子布局开始滑动,然后子布局滑动到一定距离能够吸顶,而且子布局需要支持完全滑出页面等交互效果,这种效果通常使用coordinatorlayout + appbarlayout + viewpager实现, 初步尝试发现不能完全满足视觉的效果需求,比如下拉滑走、下拉回弹这种效果不能简单实现,若要实现需要很多hook的配置方式,不好扩展及维护,最终选择利用嵌套滑动的基本原理实现了一套适合我们自己业务场景的嵌套滑动控件,能够适配更多的个性化滑动的场景需求。

3.嵌套滑动的基本原理

传统的嵌套滑动处理从事件分发的角度出发,有以下两种处理方法:

3.1 外部拦截法

外部通过调用onInterceptTouchEvent方法决定自己是否消费该事件

3.2  内部拦截法

内部通过父类的requestDisallowInterceptTouchEvent告诉外部是否要拦截事件

在传统的触摸事件分发中,如果不手动调用分发事件或者去发出事件,外部View最先拿到触摸事件,一旦它被外部View拦截消费了,内部View无法接收到触摸事件,同理,内部View消费了触摸事件,外部View也没有机会响应触摸事件。

而接下介绍的NestedScrolling机制,在一次滑动事件中外部View和内部View都有机会对滑动进行响应,这样处理滑动冲突就相对方便许多。

3.3 NestedScrolling机制原理

NestedScrolling是Android5.0推出的嵌套滑动机制,能够让父View和子View在滑动时相互协调配合实现连贯的嵌套滑动。NestScrolling需要NestScrollingChild和NestScrollingParaent相互合作完成整个流程:

image.png

3.3.1 NestScrollChild和NestScrollParaent方法介绍

//========================= NestedScrollingChild ======================================
public interface NestedScrollingChild {
    /**
     * @param enabled 开启或关闭嵌套滑动
     */
    void setNestedScrollingEnabled(boolean enabled);

    /**
     * @return 返回是否开启嵌套滑动
     */    
    boolean isNestedScrollingEnabled();

    /**
     * 沿着指定的方向开始滑动嵌套滑动
     * @param axes 滑动方向
     * @return 返回是否找到NestedScrollingParent配合滑动
     */
    boolean startNestedScroll(@ScrollAxis int axes);

    /**
     * 停止嵌套滑动
     */
    void stopNestedScroll();

    /**
     * @return 返回是否有配合滑动NestedScrollingParent
     */
    boolean hasNestedScrollingParent();

    /**
     * 滑动完成后,将已经消费、剩余的滑动值分发给NestedScrollingParent
     * @param dxConsumed 水平方向消费的距离
     * @param dyConsumed 垂直方向消费的距离
     * @param dxUnconsumed 水平方向剩余的距离
     * @param dyUnconsumed 垂直方向剩余的距离
     * @param offsetInWindow 含有View从此方法调用之前到调用完成后的屏幕坐标偏移量,
     * 可以使用这个偏移量来调整预期的输入坐标(即上面4个消费、剩余的距离)跟踪,此参数可空。
     * @return 返回该事件是否被成功分发
     */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    /**
     * 在滑动之前,将滑动值分发给NestedScrollingParent
     * @param dx 水平方向消费的距离
     * @param dy 垂直方向消费的距离
     * @param consumed 输出坐标数组,consumed[0]为NestedScrollingParent消耗的水平距离、
     * consumed[1]为NestedScrollingParent消耗的垂直距离,此参数可空。
     * @param offsetInWindow 同上dispatchNestedScroll
     * @return 返回NestedScrollingParent是否消费部分或全部滑动值
     */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    /**
     * 将惯性滑动的速度和NestedScrollingChild自身是否需要消费此惯性滑动分发给NestedScrollingParent
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @param consumed NestedScrollingChild自身是否需要消费此惯性滑动
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /**
     * 在惯性滑动之前,将惯性滑动值分发给NestedScrollingParent
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}


 
// ========================= NestedScrollingParent ======================================
public interface NestedScrollingParent {
    /**
     * 对NestedScrollingChild发起嵌套滑动作出应答
     * @param child 布局中包含下面target的直接父View
     * @param target 发起嵌套滑动的NestedScrollingChild的View
     * @param axes 滑动方向
     * @return 返回NestedScrollingParent是否配合处理嵌套滑动
     */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /**
     * NestedScrollingParent配合处理嵌套滑动回调此方法
     * @param child 同上
     * @param target 同上
     * @param axes 同上
     */
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
   
    /**
     * 嵌套滑动结束
     * @param target 同上
     */
    void onStopNestedScroll(@NonNull View target);

    /**
     * NestedScrollingChild滑动完成后将滑动值分发给NestedScrollingParent回调此方法
     * @param target 同上
     * @param dxConsumed 水平方向消费的距离
     * @param dyConsumed 垂直方向消费的距离
     * @param dxUnconsumed 水平方向剩余的距离
     * @param dyUnconsumed 垂直方向剩余的距离
     */
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    /**
     * NestedScrollingChild滑动完之前将滑动值分发给NestedScrollingParent回调此方法
     * @param target 同上
     * @param dx 水平方向的距离
     * @param dy 水平方向的距离
     * @param consumed 返回NestedScrollingParent是否消费部分或全部滑动值
     */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    /**
     * NestedScrollingChild在惯性滑动之前,将惯性滑动的速度和NestedScrollingChild自身是否需要消费此惯性滑动分
     * 发给NestedScrollingParent回调此方法
     * @param target 同上
     * @param velocityX 水平方向的速度
     * @param velocityY 垂直方向的速度
     * @param consumed NestedScrollingChild自身是否需要消费此惯性滑动
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    
    /**
     * NestedScrollingChild在惯性滑动之前,将惯性滑动的速度分发给NestedScrollingParent
     * @param target 同上
     * @param velocityX 同上
     * @param velocityY 同上
     * @return 返回NestedScrollingParent是否消费全部惯性滑动
     */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    /**
     * @return 返回当前嵌套滑动的方向
     */
    int getNestedScrollAxes();
}

3.3.2 嵌套滑动方法调用关系

image.png

目前已经实现NestScrollingChild的View有 RecycleView, ViewPager, NestScrollView

目前常用实现的NestScrollingParent的View有coordinatorlayout, NestScrollView

4.个人主页嵌套滑动布局实现

基于上述嵌套滑动原理,在Lofter个人主页的业务场景中,实现一套实现NestScrollingParent接口的控件。

4.1 实现步骤

4.1.1 页面任务拆分

  1. 初始向上滑动时内部卡片内容区域不滑动,大卡片跟随手势向上滑动
  2. 上滑到个人信息部分完全遮盖的时候,内部卡片开始跟着手势滑动
  3. 下滑时候内部卡片优先下滑,滑到最顶部时,外部卡片跟着手势向下滑动。滑动到收起的临界值是完全收起上层可滑动页面
滑动过程中主要区域 页面区域划分图1 滑动过程中主要变量图2

滑动Content部分时利用View的TransitionY属性改变位置来消耗滑动值,根据Content部分的TransitionY设定各种范围从而计算百分比来执行位移、Alpha效果。下面来说明上图中变量的意义:

topHeight: 初始时候卡片距离顶部的距离 228dp

topBarHeight: TopBar区域的高度 88dp

contentTransY: 滑动卡片的滑动距离 topHeight + topContentBar区域的高度

downFlingCutOff: 中间态卡片的回弹的下限距离

topBarAlphaOffset: TopBar区域背景透明度开始渐变的下限距离

downEndy: 卡片滑动的最大距离 LLContentBar区域的高度 +TopContentBar区域的高度 + 头像框区域的高度的一半

4.1.2 提供的对外回调

interface ProgressUpdateListener {
    // 从中间状态到完全滑动顶部百分比的变化
    fun onUpTitleBarChangeByPercent(pre: Float)
    // 从头像框呈现到头像框完全遮住百分比的变化
    fun onUpTitleBarChangeByHeaderPercent(pre: Float)
    // 滑动区域完全滑动顶部的回调
    fun onScrollToTop(isAnimator: Boolean)
    // 滑动区域活动完全离开屏幕的回调
    fun onScrollToEnd(isAnimator: Boolean)
    // 滑动区域的初始状态的回调
    fun onScrollToMiddle(isAnimator: Boolean)
}

4.2 代码部分解析

实现NestedScrollingParent2接口部分:

4.2.1 onStartNestedScroll()处理场景

场景1: LLContentBar区域 没有滑动到最顶顶部,此时完全由LLContentBar自己处理滑事件,不接受子view的滑动事件

场景2: LLContentBar已经滑动化TopBarHeight的位置,此时由 LLContentBar是否在最顶部决定要不要接受子view的滑动事件

场景3:LLContentBar已经滑动化TopBarHeight的位置,LLContentBar已经滑到最顶端,且目前的手势滑动方向是向下滑动,此时需要接受滑动事件,处理自身的滑动

4.2.2 onNestedPreScroll()处理场景

场景1:dy > 0向上滑动,滑动到topBarHeight的位置 不消耗子view dy值

场景2:dy >0 继续向上滑动时候,需要判断 LLContentBar是否没有滑动,如果没有滑动,需要滑动LLContentBar中commonTopBar的距离高度

场景3:dy <0 先判断SubChildNestScrollVIew是否有滑动,如果有滑动需要先把滑动距离归0,然后继续判断滑动距离是否在topHeight 和downEndy之间的距离,如果在的化正常滑动。如果超出downEndy,停止滑动

4.2.3:onStopNestedScroll处理场景

场景1:如果在动画执行过程中或者移动位置是初始位置的情况下不处理

场景2:如果停止时 移动的距离大于回弹动画的下限直接将LLContentBar区域滑动到最底部,如果距离大于初始位置小于惯性动画的下限执行回弹动画。如果距离小于初始位置距离不处理。

// onStartNestedScroll()
这里要注意,如果滑动的SubChildNestScrollView, 如果该View没有滑动到顶部 滑动事件全部交由该view自己处理,主要为了处理内部View的滑动冲突
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
    //只接受内容View的垂直滑动 子view接受滑动事件时候父view不响应事件,全部委托子view自己处理
    var handleChildView = true
    if (target is SubChildNestScrollVIew && !target.isSlideToBottom()) {
        handleChildView = mLlContent?.translationY != topBarHeight
        // NTLog.d("PersonDetailNestedScrollLayout", "Transform Event RecycleView $handleChildView")
    }
    if (target is SubChildNestScrollVIew && !target.isSlideToTop()) {
        handleChildView = false
        // NTLog.d("PersonDetailNestedScrollLayout", "Transform Event RecycleView 1 $handleChildView")
    }
    if (target is SubChildNestScrollVIew && mLlContent?.translationY == topBarHeight
        && target.isSlideToTop() && !target.upScroll()) {
        handleChildView = true
        // NTLog.d("PersonDetailNestedScrollLayout", "Transform Event Last $handleChildView")
    }
    return enableScroll && axes == ViewCompat.SCROLL_AXIS_VERTICAL && handleChildView
}
 
// onNestedPreScroll()
根据dy处理上滑下滑,以及事件回调
override fun onNestedPreScroll(
    @NonNull target: View,
    dx: Int,
    dy: Int,
    @NonNull consumed: IntArray,
    type: Int
) {
    if (!enableScroll || hasStartrestoreOrExpandAnimator || hasStartRebounderAnimator ||
        reboundedAnim?.isStarted ==true || restoreOrExpandAnimator?.isStarted == true || hasAutoFling) {
        // NTLog.d("PersonDetailNestedScrollLayout", "End NestPre")
        hasAutoFling = true
        return
    }
    // NTLog.d("PersonDetailNestedScrollLayout", "NestPre$dy")
    val contentTransY1 = mLlContent!!.translationY - dy
    //处理上滑
    if (dy > 0) {
        if (contentTransY1 >= topBarHeight) {
            translationByConsume(mLlContent, contentTransY1, consumed, dy.toFloat())
        } else {
            translationByConsume(
                mLlContent, topBarHeight, consumed,
                mLlContent!!.translationY - topBarHeight
            )
            if (dy > 0 && (target is SubChildNestScrollVIew) && target.isSlideToTop()) {
                var view = target
                if (abs(view.scrollY) <= view.topHeight ) {
                    view.scrollTo(0, view.topHeight )
                }
            }
        }
    }

    // 处理子view顶部bar
    if (dy < 0 && target is SubChildNestScrollVIew && target.scrollY > 0) {
        var view = target
        if (view.scrollY > 0 && abs(view.scrollY) <= view.topHeight) {
            view.scrollTo(0, 0)
        }
        return
    }

    // 处理卡片自身下滑逻辑
    if (dy < 0 && !target.canScrollVertically(-1)) {
        //下滑时处理Fling,完全折叠时,下滑Recycler(或NestedScrollView) Fling滚动到列表顶部(或视图顶部)停止Fling
        if (type == ViewCompat.TYPE_NON_TOUCH && mLlContent!!.translationY == topBarHeight) {
            return
        }

        //处理下滑
        if (contentTransY1 in topBarHeight..downEndY) {
            translationByConsume(mLlContent, contentTransY1, consumed, dy.toFloat())
        } else {
            translationByConsume(
                mLlContent,
                downEndY,
                consumed,
                downEndY - mLlContent!!.translationY
            )
            if (target is RecyclerView) {
                target.stopScroll()
            }
            if (target is NestedScrollView || target is SubChildNestScrollVIew) {
                //模拟DONW事件停止滚动,注意会触发onNestedScrollAccepted()
                val motionEvent = MotionEvent.obtain(
                    SystemClock.uptimeMillis(),
                    SystemClock.uptimeMillis(),
                    MotionEvent.ACTION_DOWN,
                    0f,
                    0f,
                    0
                )
                target.onTouchEvent(motionEvent)
            }
        }

    }
    //根据upCollapsedContentTransPro,设置折叠内容位移
    val upCollapsedContentTransPro = upContentTransChange
    transCollapsedContentByPro(upCollapsedContentTransPro)
    //根据downContentAlphaPro,设置滑动内容、收起按钮的透明度
    val downContentAlphaPro = downContentPercent

    if (mProgressUpdateListener != null) {
        mProgressUpdateListener!!.onDownContentUpChangeByPercent(downContentUpChangePercent)
        mProgressUpdateListener!!.onDownContentChangeByPercent(downContentAlphaPro)
        if (mLlContent!!.translationY <= contentTransY) {
            mProgressUpdateListener!!.onUpTitleBarChangeByPercent(upContentTransChangePercent)
            mProgressUpdateListener!!.onUpTitleBarChangeByHeaderPercent(upContentTransChangeHeaderPercent)
        } else {
            pull = true
        }
        if (mLlContent!!.translationY != lastTransLatDistance && mLlContent!!.translationY <= topBarHeight && dy >= 0) {
            mProgressUpdateListener?.onScrollToTop(false)
        }

        if (mLlContent!!.translationY != lastTransLatDistance && mLlContent!!.translationY == contentTransY) {
            mProgressUpdateListener?.onScrollToMiddle(false)
            hasVirbute = true
            pull = false
        }
        lastTransLatDistance = mLlContent!!.translationY
    }
    enableScroll = contentTransY1 < downEndY
}
 
// onStopNestedScroll()
override fun onStopNestedScroll(target: View, type: Int) {
    mParentHelper.onStopNestedScroll(target, type)
    var tran = mLlContent!!.translationY
    hasAutoFling = false
    //如果不是从初始状态转换到展开状态过程触发返回
    if (tran == contentTransY || reboundedAnim?.isStarted == true || restoreOrExpandAnimator?.isStarted == true
        || hasStartRebounderAnimator || hasStartrestoreOrExpandAnimator) {
        return
    }

    if (tran >= contentTransY) {
        if (tran > contentTransY + downFlingCutOffY) {
            expand(0)
        } else {
            restore(0)
        }
    }
}
 

4.3 处理惯性滑动

惯性处理通常有两种方式:

一、在onNestedPreFling()或者onNestedFling()返回值为true消费掉。事件不会继续传递。

二、在onNestedPreFling()和onNestedFling()返回值都为false的前提下,此时事件会继续在onNestedPreScroll()或者onNestedScroll()消费掉,此时会复用上文的onNestedPreScroll和onNestedScroll相关逻辑。

处理惯性从以下四个场景考虑

场景1:快速往上滑动LLContentBar部分的可滑动View产生惯性滑动,这和前面onNestedPreScroll()处理上滑的效果一模一样,因此可以复用逻辑,使用第二种方式处理。直接return false

场景2:在折叠状态并LLContentBar部分的可滑动View没有滑动到顶部尽头时,快速往下滑动LLContentBar部分的可滑动View产生惯性滑动滑到顶部尽头就停止了,此时应该由内部view自己处理滑动事件

场景3:快速往下滑动LLContentBar部分的可滑动View转化展开状态产生惯性滑动,这和前面onNestedPreScroll()处理下滑的效果类似,使用第二种方式处理,但注意在惯性滑动没有完全消费掉的时候,会不断触发onNestedPreScroll()来消费直到惯性滑动完全消费掉,所以当滑动到展开状态的时候要停止LLContentBar部分的View滑动因为这时已经是展开状态了,不需要ParentScrollView继续滑动触发onNestedPreScroll()

场景4:快速往下滑动LLContentBar部分的可滑动View,从非折叠状态转化展开状态产生惯性滑动,因为有回弹效果,所以逻辑处理和onNestedPreScroll()不一样,使用第一种方式处理。

override fun onNestedPreFling(
    @NonNull target: View,
    velocityX: Float,
    velocityY: Float
): Boolean {
    if (velocityY < 0) {
        if (target is SubChildNestScrollVIew && !target.isSlideToTop()) {
            return true
        }
        var translationY: Float = mLlContent?.translationY?:0f
        var dy = translationY - velocityY

        //从展开状态下滑时处理回弹Fling
        if (translationY >= 0 && translationY <= downFlingCutOffY) {
            if (dy > contentTransY && dy < downFlingCutOffY) {
                // NTLog.d("PersonDetailNestedScrollLayout", "reboundedAnim 1")
                rebounded!!.setFloatValues(translationY, dy, contentTransY)
                rebounded!!.start()
            } else if (dy > downFlingCutOffY) {
                // NTLog.d("PersonDetailNestedScrollLayout", "reboundedAnim 2")
                reboundedAnim!!.setFloatValues(translationY, downFlingCutOffY, contentTransY)
                rebounded!!.start()
            }
            // target.scrollTo(0, 0)
            return true
        }

        //从初始状态到关闭状态下滑百分比超过50%惯性滑动关闭
        return if (translationY > downFlingCutOffY) {
            expand(0)
            true
        } else {
            restore(0)
            true
        }
    }
    return false

}

4.4 释放资源

/**
 * 释放资源
 */
fun releaseFromWindow() {
    if (restoreOrExpand?.isStarted == true) {
        restoreOrExpand!!.cancel()
        restoreOrExpand!!.removeAllUpdateListeners()
        restoreOrExpand = null
    }
    if (rebounded!!.isStarted) {
        rebounded!!.cancel()
        rebounded!!.removeAllUpdateListeners()
        rebounded = null
    }
    if (mProgressUpdateListener != null) {
        mProgressUpdateListener = null
    }
}

4.5 回弹动画

rebounded = ValueAnimator()
rebounded!!.interpolator =  EaseCubicInterpolator.ease()
rebounded!!.addUpdateListener { animation ->
    translation(mLlContent, animation.animatedValue as Float)
    //根据upCollapsedContentTransPro,设置折叠内容位移
    val upCollapsedContentTransPro = upContentTransChange
    transCollapsedContentByPro(upCollapsedContentTransPro)
}
rebounded!!.addListener(object : Animator.AnimatorListener {
    override fun onAnimationStart(animation: Animator?) {
        hasStartRebounderAnimator = true
    }

    override fun onAnimationEnd(animation: Animator?) {
        if (mLlContent!!.translationY == contentTransY && mLlContent!!.translationY != lastTransLatDistance) {
            mProgressUpdateListener?.onScrollToMiddle(true)
            hasVirbute = true
            pull = false
        }
        lastTransLatDistance = mLlContent!!.translationY
        hasStartRebounderAnimator = false
    }

    override fun onAnimationCancel(animation: Animator?) {
        hasStartRebounderAnimator = false
    }

    override fun onAnimationRepeat(animation: Animator?) {
    }
})
// 时间300ms
rebounded!!.duration = ANIM_DURATION_FRACTION_ENTER

4.6 展开收起动画

restoreOrExpand = ValueAnimator()
restoreOrExpand!!.addListener(object : Animator.AnimatorListener  {
    override fun onAnimationStart(animation: Animator?) {
        hasStartrestoreOrExpandAnimator = true
    }

    override fun onAnimationEnd(animation: Animator?) {
        if (mLlContent!!.translationY >= downEndY && downTrend && mLlContent!!.translationY != lastTransLatDistance) {
            mProgressUpdateListener?.onScrollToEnd(pull)
        }
        if (mLlContent!!.translationY == contentTransY && mLlContent!!.translationY != lastTransLatDistance) {
            mProgressUpdateListener?.onScrollToMiddle(true)
            hasVirbute = true
            pull = false
        }
        lastTransLatDistance = mLlContent!!.translationY
        hasStartrestoreOrExpandAnimator = false
    }

    override fun onAnimationCancel(animation: Animator?) {
        hasStartrestoreOrExpandAnimator = false
    }

    override fun onAnimationRepeat(animation: Animator?) {
    }
})
restoreOrExpand!!.addUpdateListener { animation ->
    translation(mLlContent, animation.animatedValue as Float)

    //根据upCollapsedContentTransPro,设置折叠内容位移
    val upCollapsedContentTransPro = upContentTransChange
    transCollapsedContentByPro(upCollapsedContentTransPro)
    if (hasVirbute && downTrend && mLlContent!!.translationY != lastTransLatDistance && mLlContent!!.translationY > downCollapsedOffY) {
        doVirbute()
    }
    enableScroll = (animation.animatedValue as Float) < downEndY
}

4.7 实现NestedScrollingChild2接口部分

这部分处理比较简单,因为大多数控件已经实现NestedScrollingChild2的效果,这边重新继承NestedScrollView。主要是为了解决以下几个问题:

  1. 统一NestedScrollingChild2中的类型,方便在NestScrollingParaent中统一控制直接管理子View对象

  2. 通过提前计算RecycleView的高度,恢复RecyclewView的复用机制 (注意有坑:Recyclew嵌套在NestScrollView中本身的缓存机制会失效)

  3. 实现文章,粮单等各个tab头部状态栏的跟随滑动效果 (业务需求)

class SubChildNestScrollVIew : NestedScrollView {

    private var recyclerView: RecyclerView? = null
    private var subChildTop: ViewGroup? = null
    private var lastMesureHeight = 0
    private var loadingView: ViewGroup? = null
    private var parentHelper: NestedScrollingParentHelper? = null
    private var isUp = false
    var topHeight = 0

    constructor(context: Context) : super(context) {
        parentHelper = NestedScrollingParentHelper(this)
    }
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        parentHelper = NestedScrollingParentHelper(this)
    }
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    ) {
        parentHelper = NestedScrollingParentHelper(this)
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        recyclerView = findViewById(R.id.recycler_view)
        subChildTop = findViewById(R.id.sub_top_height)
        loadingView = findViewById(R.id.loading_view)
        recyclerView?.addOnScrollListener(object : RecyclerView.OnScrollListener () {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)
            }

            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy > 0) {//下滑动作
                    isUp = true
                }

                if (dy < 0) {//上滑动作
                    isUp = false
                }
            }
        })
    }

    // 这里重新计算主要是为了 recyclerView能获取真实的高度 不然recylerview的复用机制会失效
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        if (lastMesureHeight != recyclerView?.measuredHeight) {
            val params1 = loadingView?.layoutParams
            (measuredHeight - 0).also { params1?.height = it }
            loadingView?.layoutParams = params1
            topHeight = subChildTop?.measuredHeight ?: 0

            val params = recyclerView?.layoutParams
            (measuredHeight - 0).also { params?.height = it }
            recyclerView?.layoutParams = params
            lastMesureHeight = measuredHeight
            NTLog.w("SubChildNestScrollVIew", "SubChildNestScrollVIew measure")
        }

        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }


    override fun canScrollVertically(direction: Int): Boolean {
        return recyclerView?.canScrollVertically(direction) == true
    }

    fun isSlideToBottom(): Boolean {
        if (recyclerView == null) return false
        return !recyclerView!!.canScrollVertically(1)
    }

    fun isSlideToTop(): Boolean {
        if (recyclerView == null) return false;
        return !recyclerView!!.canScrollVertically(-1)
    }

    public fun upScroll(): Boolean {
        return isUp
    }
    
    # 如果scrollY有滑动距离,该View不响应事件,全部交给子view自己处理事件
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        if (scrollY != 0) {
            return false
        }
        return super.onInterceptTouchEvent(ev)
    }
}