NestedScrolling机制的最佳实践

1,404 阅读7分钟

前言:

在一个阳光明媚的中午,我按时的打开美团App准备点一份便宜实惠可口的午餐,正在我想中午准备吃什么的时候(世界级难题:“中午吃什么?”),我在一个点餐页面看的时候,不由自主的随便滑了两下,发现这个滑动效果真不错,于是我就接着在这个页面无情的巴拉无情的疯狂滑动,观察其中元素的效果,发现这是一个有两次吸顶效果的页面,但这个如果简单的写监听滑动看起来不是那么很好的完成这种效果,于是就想可能是利用嵌套滚动实现的这个效果,想着想着,糟糕......我忘记点餐了,可恶....今天要下楼吃饭了!

嵌套滚动

说起来嵌套滑动,估计先想到的是从传统的事件分发入手,但可惜这篇抛开传统的事件分发,利用NestedScrilling机制实现。

emmmm其实这篇我主要说怎么实现这种效果,具体的NestedScrolling的知识可以看看,这篇(从中我学到了不少知识,非常感谢):

浅析NestedScrolling嵌套滑动机制之基础篇

正片

先看一下美团点餐页的效果

最后实现的效果:

滑动其分为三步:

  • 整体页面的滑动(点菜TabLayout到达顶部导航之前)

  • 点餐联动模块上方的Banner到TabLayout的滑动

  • 点餐详情模块滑动

页面大体也能分三个组件:

  • 头部组件

  • TabLayout组件

  • ViewPager组件(三个Fragment

分析

  在我反复滑动过程中发现,TabLayout以上的是不能滑动的,但是下方是可以滑动的,也就是说在整个包揽这些组件的Container并不是一个可滑动的View,大胆猜想可能是利用上滑Y轴的坐标来给移动TabLayout以及下方的组件

     中间有一层有折叠效果的View,滑动发现,其实这是被下层布局遮挡之后,看起来像是折叠效果,实际上就是一个下方容器跟着下滑 滑动到一定距离进行下滑动画。

      其次点餐的Fragment里面banner以及banner下方的内容都是可以滑动,所以很大可能是利用NestedScrollView,因为这种不能是一个RecyclerView 当然,RecyclerView也不是没有可能。(我想应该不可能是RecyclerView,后面会说)

那么根据分析,即可进行大体的页面布局:

第一层:

第二层:

第三层:

组合起来就是这样:

布局代码在结尾会提供码云地址,请往下看。

在此整体页面搭建完毕。

正片(二):

这里NestedScrolling机制利用顺序:

  1. 整体自定义一个View继承FrameLayout 实现 NestedScrollingParent3接口
  2. 点菜Fragment中自定义View继承NestedScrollView(注意这是重点,因为NestedScrollView本身就继承了NestedScrollingParent3和****NestedScrollingChild3,可以作为一个承上启下分发传递的作用**)**
  3. NestedScrollView中包含两个可以联动的RecyclerView(RecyclerView本身实现NestedScrollingChild3接口

所以这里的分发顺序

这里还是有不解的话,可以看看文档具体的先后顺序(百度一下,不行就Google一下);

既然顺序图有了,上代码:

1.MTScrollLayout

private View mHeaderNavigationLayout; //顶部导航view
private View mFoldContainerLayout; //可折叠内容view
private View mContainerLayout; //container所有的内容view
private View mTopContainerLayout;//上层view高度
private View mSwiperContainerLayout; //下层可滑动联动效果lview
private ImageView mBackOut;
private HeaderSearchImageView searchImageView;
private ImageView mMoreImage;
private View mBgImage;
private View mTabLayout;
private float mStartTransY; //起始transY移动距离
private float mMinContainerTransY; //到达顶部的transY最小距离
private float swiperTransY; //swiper第一层置顶的滑动的起始距离
private float headerHeight;

public MTScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);

}

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    initView();

}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    mStartTransY = getResources().getDimension(R.dimen.translate_fold_Y);
    headerHeight = getResources().getDimension(R.dimen.top_bar_height);
    Log.d("yumi",headerHeight + "");
    //这里三个计算主要是为了计算view保证在移动完成后,包含顶部栏以及移动位置加起来需要做到整个view不超过屏幕
    swiperTransY = getResources().getDimension(R.dimen.swiper_container_transY);
    ViewGroup.LayoutParams mFoldContainerLayoutParams = mFoldContainerLayout.getLayoutParams();
    mFoldContainerLayoutParams.height = (int) (getMeasuredHeight() - mStartTransY);
    ViewGroup.LayoutParams mContainerLayoutParams = mContainerLayout.getLayoutParams();
    mContainerLayoutParams.height = mFoldContainerLayoutParams.height;
    ViewGroup.LayoutParams swiperLayoutParams = mSwiperContainerLayout.getLayoutParams();
    swiperLayoutParams.height = (int) (getMeasuredHeight() - headerHeight);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

}


@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);

    headerHeight = headerHeight;

}
private void initView(){
    mFoldContainerLayout = findViewById(R.id.fold_container_layout);
    mContainerLayout = findViewById(R.id.container_layout);
    mTopContainerLayout = findViewById(R.id.top_container_layout);
    mSwiperContainerLayout = findViewById(R.id.swiper_container_layout);
    mHeaderNavigationLayout = findViewById(R.id.header_navigation);
    mBgImage = findViewById(R.id.bg_image);
    mTabLayout = findViewById(R.id.tablayout);
    mBackOut = findViewById(R.id.back_out);
    searchImageView = findViewById(R.id.search_imagview);
    mMoreImage = findViewById(R.id.more);
}



public static int px2dip(Context context, float pxValue) {
    final float scale = context.getResources().getDisplayMetrics().density;
    return (int) (pxValue / scale + 0.5f);
}


@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {

}


@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
    return child.getId() == mSwiperContainerLayout.getId() && axes== ViewCompat.SCROLL_AXIS_VERTICAL;
}

@Override
public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {

}

@Override
public void onStopNestedScroll(@NonNull View target, int type) {
//这里做了一个简单的计算,这个计算其实可以随便一点,这是下拉一定距离之后 折叠展开的时机而已
//这里动画有欠缺,有兴趣同学可以好好写一下
    if( mSwiperContainerLayout.getTranslationY() > swiperTransY + (getHeight() -swiperTransY) / 4 ) {
           //可滑动页面向下移动动画
         mSwiperContainerLayout.animate().setDuration(200).setInterpolator(new AccelerateInterpolator()).translationY(getHeight());
    }else if(mSwiperContainerLayout.getTranslationY() > swiperTransY && mSwiperContainerLayout.getTranslationY() < swiperTransY + (getHeight() -swiperTransY) / 4){
           //可滑动页面还原动画
        mSwiperContainerLayout.animate().setDuration(200).setInterpolator(new AccelerateInterpolator()).translationY(swiperTransY);
    }
}

@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {

}

@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    float translateY = mSwiperContainerLayout.getTranslationY() - dy; //需要滑动的距离
    if(dy >0) {
        //向上滑动
            
        if(translateY >= headerHeight) {
            //“我没到达指定位置,我可不能让你先滚动,我先把你的滚动距离消费掉”
            xfScrollDy(mSwiperContainerLayout, translateY, consumed, dy);
        }else {
            //“我到达了,好了 我不在消费你的距离 把这个返还给你吧”
            xfScrollDy(mSwiperContainerLayout, headerHeight, consumed, (mSwiperContainerLayout.getTranslationY() - headerHeight));
               //“我给你个消息,你能滚动了”
             EventBus.getDefault().post(new MessageEvent(true));
        }
    }
    if(dy < 0 && !target.canScrollVertically(-1)) {
        //向下滑动
        //处理下滑
        if (translateY >= headerHeight && translateY <= swiperTransY) {
       //同上
            xfScrollDy(mSwiperContainerLayout, translateY, consumed, dy);
            EventBus.getDefault().post(new MessageEvent(false));
        }else {
             //这里判断是否是手指一直在触摸屏幕产生滑动
            if(type == ViewCompat.TYPE_TOUCH) {
                //若是代表手指一直在向下动作,此时可滑动view跟着手值一起滑动,看上去是折叠效果
                xfScrollDy(mSwiperContainerLayout, translateY, consumed, dy);
            }else {
                //若是非手指一直在触摸,是手指松开,滚动自动的
                //停止 不在向下滑动
                xfScrollDy(mSwiperContainerLayout, swiperTransY, consumed, (mSwiperContainerLayout.getTranslationY() - swiperTransY));
            }
        }
    }
/**
* 这里做一些简单的距离百分比计算,用于一些基础渐变动画
* 这里没有对所有图片以及view进行动画以及渐变展示
* 大家可以根据自己的动效特点,进行相应计算,进行动画
*/
    float fix = (swiperTransY - MathUtils.clamp(mSwiperContainerLayout.getTranslationY(),headerHeight,swiperTransY)) / (swiperTransY - headerHeight);
    float containerTransY = mStartTransY -(fix * (swiperTransY - headerHeight));
    float bgImageTransY = 0-(fix * (swiperTransY - headerHeight));
    mContainerLayout.setTranslationY(containerTransY);
    mBgImage.setTranslationY(bgImageTransY);
    float navigationAlpha = MathUtils.clamp((fix * 2) * 255,0,255);
    float mtablayoutAlpha = MathUtils.clamp((fix) * 255,0,255);
    if(navigationAlpha > 255.0f / 1.2) {
        // 滚动到距离头部一定距离
        //ThemeUtil是我的一个状态栏工具类  这里让其字体变黑
        ThemeUtil.setStatusBarTransparent((Activity) getContext(),true,false);
        mBackOut.setImageResource(R.mipmap.back_black);
        mMoreImage.setImageResource(R.mipmap.more_black);
    }else {
        //这里让其字体变白 并且跟换相应的图片
        ThemeUtil.setStatusBarTransparent((Activity) getContext(),false,false);
        mBackOut.setImageResource(R.mipmap.houtui);
        mMoreImage.setImageResource(R.mipmap.more);
    }
    mHeaderNavigationLayout.setBackgroundColor(Color.argb((int)navigationAlpha,250,250,250));
    mTabLayout.setBackgroundColor(Color.argb((int)mtablayoutAlpha,250,250,250));
    //这里是自定义的searchView 因为这里主要利用canvas实现的动画效果,可参考下面
    searchImageView.setDrawLeft(fix * 2);
}

@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
    return false;
}
//消费dy
private void xfScrollDy(View view, float translationY, int[] consumed, float consumedDy) {
    consumed[1] = (int) consumedDy;
    view.setTranslationY(translationY);
}
 EventBus.getDefault().post(new MessageEvent(true));

这里为什么要用EventBus?因为在需要两个组件的进行通信,告知子组件我已经到位置了,我不在消费你的滑动距离了。(这里肯定有更好的方式)

onStartNestedScroll // 首先判断是不是这个组件跟父组件对话的,并且是垂直滚动

onNestedPreScroll //滑动之前 父组件要对子组件准备消费的距离进行处理,看情况消费这个距离

onStopNestedScroll //手指释放滑动之后,所触发的方法

其中指一下float距离意义:

中间层的衔接上层以及下层的NestedScrollView:

private View mRecyclerViewContainer;
private RecyclerView mClassifyRecyclerView;
private RecyclerView mDetailContentRecyclerView;
private  float mContentHeader;
private float mHeaderHeight;
private boolean ishuadong = false; //判断是否可以滑动了
public MyNestedScrollView(@NonNull Context context) {
    super(context);

}
public MyNestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    helper = new NestedScrollingChildHelper(this);
    EventBus.getDefault().register(this);
}

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    initView();
}

private void initView(){
    mRecyclerViewContainer = findViewById(R.id.recycler_container);
    mClassifyRecyclerView = findViewById(R.id.classify_recyclerview);
    mDetailContentRecyclerView = findViewById(R.id.detail_content_recyclerview);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    mContentHeader =getResources().getDimension(R.dimen.translate_fold_Y) - getResources().getDimension(R.dimen.dp_30);
    mHeaderHeight = getResources().getDimension(R.dimen.dp_200);
       //设置banner下面点餐内容View的高度确保与当前fragment高度 - banner一样
     ViewGroup.LayoutParams params = mRecyclerViewContainer.getLayoutParams();
    params.height = (int) (getMeasuredHeight() - mContentHeader);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

@Subscribe(threadMode = ThreadMode.MAIN)
public void onMessageEvent(MessageEvent event) {
    ishuadong = event.isFlag();
};
@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
    //这两个targetid 主要判断发来询问的组件是不是reyclerview 以及 是垂直方向的滑动
    return ((target.getId() == mClassifyRecyclerView.getId()) || (target.getId() == mDetailContentRecyclerView.getId())) && axes == ViewCompat.SCROLL_AXIS_VERTICAL;
}

@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    int scrollY = getScrollY() + dy;
    if(dy > 0) {
        //上滑
        if(!ishuadong) {
            //如果不能滑动 调用父类默认方法 让他的父组件自己处理
            super.onNestedPreScroll(target, dx, dy, consumed, type);
        }
        if(getScrollY() <= mHeaderHeight && ishuadong) {
            //MTScrollLayout 通知我能滑动了
            //我还没到达指定位置 我不能让下面的recyclerview进行滑动 我要消费掉滑动距离
               //调用滑动方法让nestedscrollview自己滑动
            scrollTo(0,scrollY);
            consumed[1] = (int) dy;
            //这里处理 滑动过程中 用户突然 手指向下滑动,导致距离会不停地弹跳
            //注意一定要处理,可以注释掉试一下看看有什么神奇的效果
            if(type == ViewCompat.TYPE_NON_TOUCH && (mClassifyRecyclerView.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING || mDetailContentRecyclerView.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING)) {
                
                mDetailContentRecyclerView.stopScroll();
                mClassifyRecyclerView.stopScroll();
            }

        }
    }
    if(dy < 0 && !canScrollVertically(-1)) {
        //下滑
        //我滚动到顶部了,我直接调用我的父类默认实现 让他自己处理
        super.onNestedPreScroll(target, dx, dy, consumed, type);
    }
}

@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
    super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);
        //非触摸状态下
    if(type == ViewCompat.TYPE_NON_TOUCH) {
        //惯性滑动 
        if(!canScrollVertically(-1)) {
            //直接复用滑动逻辑 否则不能流畅的向下滑动进行连贯效果
            onNestedPreScroll(target,dxUnconsumed,dyUnconsumed,consumed,type);
        }
    }
}

下面是展示图 变量

很好,这样基本是可以看做大部分已经结束了

接下来就是两个RecyclerView联动效果了:

classifAdapter.setOnItemClickListener(new OnItemClickListener() {
    @Override
    public void onItemClick(@NonNull BaseQuickAdapter<?, ?> adapter, @NonNull View view, int position) {
        contentRecyclerView.smoothScrollToPosition(position);
    }
});

只贴了部分代码,右侧的滑动联动效果,可以监听recyclerview滑动,利用LinearLayoutManager寻找当前屏幕的第一个item,拿到position与其左侧列表item进行定位 即可实现联动,这里不在赘述,详情请看demo代码!

demo地址:仿美团效果demo

最后感谢掘金上面发表过文字的各路大神,若不是这些我可能对这些还不很了解!

在以后的道路上还要不断地提升自己!

若文章有不妥之处 欢迎指出,欢迎提出各种比我这个更好的解决方案,我会一 一学习~