前言:
在一个阳光明媚的中午,我按时的打开美团App准备点一份便宜实惠可口的午餐,正在我想中午准备吃什么的时候(世界级难题:“中午吃什么?”),我在一个点餐页面看的时候,不由自主的随便滑了两下,发现这个滑动效果真不错,于是我就接着在这个页面无情的巴拉无情的疯狂滑动,观察其中元素的效果,发现这是一个有两次吸顶效果的页面,但这个如果简单的写监听滑动看起来不是那么很好的完成这种效果,于是就想可能是利用嵌套滚动实现的这个效果,想着想着,糟糕......我忘记点餐了,可恶....今天要下楼吃饭了!
嵌套滚动
说起来嵌套滑动,估计先想到的是从传统的事件分发入手,但可惜这篇抛开传统的事件分发,利用NestedScrilling机制实现。
emmmm其实这篇我主要说怎么实现这种效果,具体的NestedScrolling的知识可以看看,这篇(从中我学到了不少知识,非常感谢):
正片
先看一下美团点餐页的效果
最后实现的效果:
滑动其分为三步:
-
整体页面的滑动(点菜TabLayout到达顶部导航之前)
-
点餐联动模块上方的Banner到TabLayout的滑动
-
点餐详情模块滑动
页面大体也能分三个组件:
-
头部组件
-
TabLayout组件
-
ViewPager组件(三个Fragment)
分析
在我反复滑动过程中发现,TabLayout以上的是不能滑动的,但是下方是可以滑动的,也就是说在整个包揽这些组件的Container并不是一个可滑动的View,大胆猜想可能是利用上滑Y轴的坐标来给移动TabLayout以及下方的组件
中间有一层有折叠效果的View,滑动发现,其实这是被下层布局遮挡之后,看起来像是折叠效果,实际上就是一个下方容器跟着下滑 滑动到一定距离进行下滑动画。
其次点餐的Fragment里面banner以及banner下方的内容都是可以滑动,所以很大可能是利用NestedScrollView,因为这种不能是一个RecyclerView 当然,RecyclerView也不是没有可能。(我想应该不可能是RecyclerView,后面会说)
那么根据分析,即可进行大体的页面布局:
第一层:
第二层:
第三层:
组合起来就是这样:
、
布局代码在结尾会提供码云地址,请往下看。
在此整体页面搭建完毕。
正片(二):
这里NestedScrolling机制利用顺序:
- 整体自定义一个View继承FrameLayout 实现 NestedScrollingParent3接口
- 点菜Fragment中自定义View继承NestedScrollView(注意这是重点,因为NestedScrollView本身就继承了NestedScrollingParent3和****NestedScrollingChild3,可以作为一个承上启下分发传递的作用**)**
- 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
最后感谢掘金上面发表过文字的各路大神,若不是这些我可能对这些还不很了解!
在以后的道路上还要不断地提升自己!
若文章有不妥之处 欢迎指出,欢迎提出各种比我这个更好的解决方案,我会一 一学习~