前言
最近在做一些项目和毕业设计,所以自从上次梳理完数据结构之后,一直想写些什么,但是又没有比较好的内容,所以博客从过完年之后就停更了很长很长很长一段时间,不过这次在做项目的时候,正好遇到一个我本以为很简单,结果折腾了好久的一个问题,其实这个问题对于做Android开发的同学来说,并不陌生,那就是滑动布局的互相嵌套。
当然并不仅限于标题中写的这种嵌套,只要是可滑动的布局,例如GridView,各种自定义的可滑动控件等等,都会遇到这个问题,如果嵌套相对简单的话,例如ScrollView
嵌套RecyclerView
,解决滑动冲突的办法就简单点,可以使用NestedScrollView
替代ScrollView
或者重写RecyclerView
的onMeasure
方法,都可以达到解决冲突的效果,但是一但嵌套复杂起来,像ScrollView+TabLayout+ViewPager+RecyclerView
这种,情况就不一样了,那么下面分享下我这次遇到的问题,以及整个探索过程,包括我最后的解决办法。
目录
1、需要实现的效果
2、探索过程
3、解决方案
正文
1、需要实现的效果
在项目中,我需要实现的效果其实在很多APP里都可以找到,由于项目需求明确写明是仿京东发现页面,所以是需要实现一个类似京东发现页面的效果,我们来到京东的发现页面,效果如下
这里把图中的效果再总结一下:
1、整体布局分为顶部标题栏,顶部导航栏,下方可左右滑动的内容区,导航栏和内容区是联动的
2、整体页面任意位置往上滑动时,若标题栏在屏幕可见区域内,则标题栏会滑出屏幕,导航栏会悬浮在顶部
3、整体页面任意位置往下滑动时,若标题栏之前被滑出了屏幕,则标题栏随下滑而出现在导航栏上方(图中没有演示出来,实际是有这个效果的)
4、若内容区滑到最顶部,再往下拉时,会产生下拉刷新
2、探索过程
2.1 我最初的实现思路
首先看到这个布局,我的思路很清晰,当然也想当然的认为这样实现没有问题,因为之前也遇到过类似的布局,虽然没这个复杂,但是最后都解决了。
首先整个页面肯定是一个Activity
的布局,下方的底部导航栏就暂且忽略,不是本文的重点,我们需要关注的就是中间这个页面(就是一个Fragment
)的布局,由于整个页面是可滑动的,所以布局最外层肯定是一个ScrollView
,布局最上面是一个标题栏,这个没啥好说的,标题栏下面是一个导航栏,导航栏的实现也不陌生了,就是一个TabLayout
,导航栏下面是内容区,内容区可左右滑动,很明显,内容区可使用ViewPager
实现,在内容区里,是一条条的文章数据,这个我的实现思路是使用RecyclerView
,虽然你会发现有些布局项和其它不同,但是没关系,我们可以通过适配器中的viewType
来控制,然后导航栏和内容区的联动效果可使用TabLayout
的setupWithViewPager
方法直接实现联动效果。
上面的思路实现了整体的布局效果,但是一些小细节还没有实现:
细节一:TabLayout
滑到顶部时,悬浮在顶部,而标题栏直接滑出屏幕,这个效果要实现,主要是实现一个滑动监听, 我们可以监听最外层ScrollView
的滑动,根据参数来判断TabLayout
是否滑到了屏幕顶部,如果滑到了顶部,则在布局中移除原TabLayout
,然后可以在屏幕顶部位置写一个空的顶部布局,再将TabLayout
添加到这个空的布局中。反之,如果ScrollView
滑动到了顶部,此时应该将TabLayout
从顶部布局中移除,将TabLayout
回归原位,涉及到的方法主要有addView
和removeView
。
细节二:整体布局任意位置下滑时,如果标题栏之前被滑出了屏幕,那么会随下滑而逐渐出现在屏幕顶部,这个效果,我最初没有去实现,因为需求中没有这个,但是我后来发现了这个细节效果,现在的思路也是和上面的一样,监听ScrollView
的滑动,根据滑动参数判断上滑还是下滑,如果是下滑的话,那么直接将标题栏布局添加到顶部布局,并根据下滑的距离慢慢将标题栏布局滑出来
2.2 尝试写代码实现
首先根据上面的整体布局思路来一步步实现,至于下面的两个细节问题,先放一放,待会再来实现,ok,有了整体思路,我们不难写出如下代码:
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<LinearLayout
android:id="@+id/container_normal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/top_info"
android:layout_width="match_parent"
android:layout_height="60dp">
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="我是标题栏"
android:textSize="18sp" />
</RelativeLayout>
<android.support.design.widget.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@android:color/white"
app:tabSelectedTextColor="@android:color/black"
app:tabMode="fixed"/>
<android.support.v4.view.ViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
</ScrollView>
现在我们尝试往这个布局中添加简单的内容,看有没有什么问题出现,为了简化,我就给ViewPager
弄三个简易的Fragment
,每个Fragment
布局如下
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="第一个页面"
android:textSize="20sp" />
</RelativeLayout>
对应的java代码如下
public class OneFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_one,container,false);
}
}
我们创建三个这样的Fragment
用于内容填充,接下来就是给tabLayout
和viewPager
设置内容,由于不是重点,我就不废话,直接放上Activity
中的相关代码,如下
public class MainActivity extends AppCompatActivity {
private TabLayout tabLayout;
private ViewPager viewPager;
private FragmentPagerAdapter mPageAdapter;
private ArrayList<String> titleList = new ArrayList<>();
private ArrayList<Fragment> fragmentList=new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
tabLayout=findViewById(R.id.tabLayout);
viewPager=findViewById(R.id.viewPager);
titleList.clear();
titleList.add("标签一");
titleList.add("标签二");
titleList.add("标签三");
fragmentList.clear();
fragmentList.add(new OneFragment());
fragmentList.add(new TwoFragment());
fragmentList.add(new ThreeFragment());
mPageAdapter=new FragmentPagerAdapter(getSupportFragmentManager()) {
@Override
public Fragment getItem(int i) {
return fragmentList.get(i);
}
@Nullable
@Override
public CharSequence getPageTitle(int position) {
return titleList.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public int getCount() {
return titleList.size();
}
};
viewPager.setAdapter(mPageAdapter);
tabLayout.setupWithViewPager(viewPager);
}
}
最终运行出来的效果如下
ok,可以明显的看到,我们明明设置了ViewPager而且设置了内容,但是却发现ViewPager
没有显示出来,第一个问题出现了,究其原因就是我们在ScrollView
中嵌套了ViewPager
,导致ViewPager
的高度计算不正确,所以我们可以通过重写ViewPager
的onMeasure
方法来重新计算高度,除此之外,还有一个简单的解决办法,就是给ScrollView
设置fillViewport
属性为true
,这个属性的作用就是让子布局中的内容铺满全屏,ok,设置了该属性之后,我们运行看效果
接下来我们继续丰富其中的内容, 将Fragment
中的内容更改为RecyclerView
的列表,看看有没有什么问题,Fragment
布局很简单,就一个RecyclerView
,我们看Fragment
对应的java代码,如下
public class OneFragment extends Fragment {
private RecyclerView mRecyclerView;
private RecyclerView.LayoutManager mLayoutManager;
private RecyclerViewAdapter mAdapter;
private View mainView;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
mainView=inflater.inflate(R.layout.fragment_one,container,false);
initView();
return mainView;
}
private void initView(){
mRecyclerView=mainView.findViewById(R.id.recyclerview);
mLayoutManager=new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false);
ArrayList<String> data=new ArrayList<>();
for(int i=0;i<20;i++){
data.add("列表项"+i);
}
mAdapter=new RecyclerViewAdapter(getActivity(),data);
mRecyclerView.setLayoutManager(mLayoutManager);
mRecyclerView.setAdapter(mAdapter);
}
}
适配器代码如下
public class RecyclerViewAdapter extends RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder> {
private Context context;
private ArrayList<String> data;
public RecyclerViewAdapter(final Context context, ArrayList<String> data) {
this.context = context;
this.data = data;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
View view = LayoutInflater.from(context).inflate(R.layout.item_rv, viewGroup, false);
return new ViewHolder(view);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
viewHolder.tv.setText(data.get(position));
}
@Override
public int getItemCount() {
return data.size();
}
class ViewHolder extends RecyclerView.ViewHolder {
private TextView tv;
ViewHolder(View itemView) {
super(itemView);
tv = itemView.findViewById(R.id.tv);
}
}
}
运行效果如下:
ok,到这里为止,我们基本上算是实现了一个整体布局的大致效果,现在我们再来实现第一个细节,TabLayout
滑到顶部悬浮的效果,要实现这个效果,我们首先需要监听到最外层的ScrollView
的滑动,这样才能根据参数来控制TabLayout
的位置,也就是说我们需要将RecyclerView
的滑动事件交给ScrollView
来执行,换句话说,就是ScrollView
需要拦截RecyclerView
的滑动事件,ok,我们只需要重写ScrollView
的onInterceptTouchEvent
方法即可对滑动事件进行拦截,自定义ScrollView
代码如下:
public class InterceptScrollView extends ScrollView {
public InterceptScrollView(Context context) {
super(context);
}
public InterceptScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public InterceptScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
super.onInterceptTouchEvent(ev);
return true;
}
}
然后在布局中,使用我们的自定义ScrollView
,而不是系统原生的,ok,我们尝试运行一下,看会不会和预期的一样,运行后效果
很遗憾,结果并不如预期,整个页面现在什么也做不了,无法滑动,无法点击,我们先解决无法滑动的问题.
为什么这个时候整个页面无法上下滑动了呢?前面说到了给ScrollView
设置fillViewport
属性为true
来解决ViewPager
内容显示不全的问题,但是这个属性在这个时候却正好帮了倒忙,当我们设置ScrovllView
拦截滑动事件的时候,只有当包裹内容超出屏幕范围的时候,ScrollView
才可以滑动(也就是整个页面才可以滑动),但是由于fillViewport
属性的设置,导致ViewPager
的布局高度始终是充满整个屏幕的,也就是说此时ScrollView
包裹的内容正好填满整个屏幕,并没有超出,自然不能滑动了。
ok,既然fillViewport
属性帮了倒忙,那么我们就去掉这个属性,但是一旦去掉这个属性,又会发生ScrollView
嵌套ViewPager
导致ViewPager
内容不显示(高度为0)的问题。
所以到这里,我们陷入了一个两难的境地。到这里我们只能回到上个问题,既然使用fillViewport
属性行不通,那我们就重写ViewPager
的onmeasure
方法来解决高度显示为0的问题。
重写ViewPager
,代码如下:
public class AutofitViewPager extends ViewPager {
public AutofitViewPager(@NonNull Context context) {
this(context,null);
}
public AutofitViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
addOnPageChangeListener(new OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
}
@Override
public void onPageSelected(int position) {
requestLayout();//保证每次选中当前页时,计算高度,达到高度自适应效果
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
}
//重写onMeasure,解决高度显示为0,同时高度动态显示为当前子项的高度
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int height = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
child.measure(widthMeasureSpec,
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
int h = child.getMeasuredHeight();
if(i==getCurrentItem()){
height=h;
}
}
heightMeasureSpec = MeasureSpec.makeMeasureSpec(height,
MeasureSpec.EXACTLY);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
ok,我们现在把这个自定义的ViewPager
放入我们的布局中,最终运行,得到如下效果
ok,现在我们的布局终于可以既显示完整,又可以滑动了,但是仔细操作,我们会发现又有两个新的问题产生了
问题一:界面初次加载的时候,ViewPager
的内容会置顶,导致标题栏和```TabLayout的内容被顶出屏幕外面 问题二:界面只能上下滑动,无法响应点击事件,
ViewPager``无法横向滑动
我们首先来解决问题一,发生问题一的原因主要是,在界面初次加载的时候发生了一个焦点争夺的问题,从效果可以看出,ViewPager
或者ViewPager
里的RecyclerView
获得了焦点,所以我们需要将焦点让最上面的布局获取,也就是标题栏的布局,Ok,我们通过给标题栏布局设置如下三个属性即可解决问题
android:focusable="true"
android:focusableInTouchMode="true"
android:descendantFocusability="beforeDescendants"
简单的解释一下第三个属性descendantFocusability
,这个属性一共有三个值,含义如下
beforeDescendants:viewgroup会优先其子类控件而获取到焦点
afterDescendants:viewgroup只有当其子类控件不需要获取焦点时才获取焦点
blocksDescendants:viewgroup会覆盖子类控件而直接获得焦点
根据我们的需求,我们需要设置为beforeDescendants。
ok,现在我们运行程序,效果如下:
现在我们再来解决第二个问题,只能上下滑动,也不能响应点击事件,我们简单分析下,我们之前重写ScrollView
的onInterceptTouchEvent
时,是直接返回的true
,代表拦截所有事件,而我们现在这个场景中, 可以发现我们只需要拦截垂直方向的滑动时间,水平方向的滑动事件不予处理,ok,按照这个思路,我们再重新写一下ScrollView
的onInterceptTouchEvent
方法,如下
private int lastInterceptX;
private int lastInterceptY;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastInterceptX;
int deltaY = y - lastInterceptY;
//如果是垂直滑动,则拦截
if (Math.abs(deltaX) - Math.abs(deltaY) < 0) {
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
}
lastInterceptX = x;
lastInterceptY = y;
super.onInterceptTouchEvent(ev);//这一句一定不能漏掉,否则无法拦截事件
return intercept;
}
ok,现在我们再来运行一下,看是不是如预期的那样,效果如下
整体上,和我们预期的一样,上下滑动是ScrollView
处理的,横向滑动是ViewPager
处理的,但是我们却发现了一个极其奇怪的现象,那就是通过点击tab按钮来切换到第三个页面时,对应的布局内容居然不显示,而通过滑动操作切换到第三个页面时,却可以正常显示。
下面我们来简单分析下,虽然这个现象很奇怪,但是如果你接触ViewPager
较多的话,那么你肯定知道ViewPager
是有缓存机制的,也就是默认会预加载左右各一个页面,而我们的demo中,很显然,初次进入的时候,加载第一个页面,同时第二个标签页对应的页面也是会加载的(预加载),而我们的第三个页面却没有预加载,这时候,直接点击tab
来切换,可能导致view
还没有加载就直接测量其高度,这时候自然是测量不到的,所以我们可以通过设置初次加载的时候预加载所有的页面来解决这个问题(虽然不优雅。
所以我们在代码中只需要给ViewPager
加上如下一行代码
viewPager.setOffscreenPageLimit(titleList.size());
这样就可以预加载所有的界面,现在我们再来运行下,看有没有刚才那个问题,效果如下
至此,我们已经实现了整个页面的布局,接下来实现细节一:TabLayout
在滑动到顶部的时候,会悬浮在屏幕上方的效果。思路之前已经讲过了,接下来就是修改代码,首先我们修改Activity
的布局代码,为其添加“悬浮在屏幕上方的空布局”,如下
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.hq.testscrollview.InterceptScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/container_normal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:descendantFocusability="beforeDescendants"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/top_info"
android:layout_width="match_parent"
android:layout_height="60dp">
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="我是标题栏"
android:textSize="18sp" />
</RelativeLayout>
<android.support.design.widget.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@android:color/white"
app:tabMode="fixed"
app:tabSelectedTextColor="@android:color/black" />
<com.hq.testscrollview.AutofitViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</com.hq.testscrollview.InterceptScrollView>
<!-- 悬浮在屏幕上方的顶部空布局-->
<LinearLayout
android:id="@+id/container_top"
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="vertical">
</LinearLayout>
</RelativeLayout>
由于顶部布局不受ScrollView
的滑动影响,所以最外层的布局就不能是ScrollView
了,然后我们在监听滑动的ScrollView
,怎么监听呢,很简单,ScrollView
默认提供了onScrollChanged
方法,我们只需要将该方法的参数暴露出去即可,如下,修改InterceptScrollView
的代码
public class InterceptScrollView extends ScrollView {
private int lastInterceptX;
private int lastInterceptY;
private ScrollChangedListener onScrollChangedListener;
public InterceptScrollView(Context context) {
super(context);
}
public InterceptScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public InterceptScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastInterceptX;
int deltaY = y - lastInterceptY;
//如果是垂直滑动,则拦截
if (Math.abs(deltaX) - Math.abs(deltaY) < 0) {
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
}
lastInterceptX = x;
lastInterceptY = y;
super.onInterceptTouchEvent(ev);//这一句一定不能漏掉,否则无法拦截事件
return intercept;
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if(onScrollChangedListener!=null){
onScrollChangedListener.onScrollChanged(l,t,oldl,oldt);
}
}
public void setScrollChangedListener(ScrollChangedListener listener){
onScrollChangedListener = listener;
}
public interface ScrollChangedListener{
void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY);
}
}
然后,在Activity
的代码中,给InterceptScrollView
添加监听,如下
mScrollView.setScrollChangedListener(new InterceptScrollView.ScrollChangedListener() {
@Override
public void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
//如果Y轴方向滑动距离超过60dp,那么就将tabLayout添加在顶部的空布局中,达到“悬浮效果”
//为什么是60dp呢,因为标题栏布局高度为60dp
if (scrollY >= DpUtils.dp2px(MainActivity.this,
60)&&tabLayout.getParent()==container_normal) {
container_normal.removeView(tabLayout);
container_top.addView(tabLayout);
} else if(scrollY < DpUtils.dp2px(MainActivity.this,
60)&&tabLayout.getParent()==container_top){//没超过60dp,则将tabLayout回归正常的布局位置
container_top.removeView(tabLayout);
//参数 1 代表布局中子布局的第1个位置处(0为第一个子布局)
container_normal.addView(tabLayout, 1);
}
}
});
运行代码,效果如下:
整体效果上还是不错的,但是,仔细观察,会发现还是存在如下这两个问题:
问题一:在TabLayout
处于即将要悬浮的临界状态时,因为TabLayout
被从原布局中突然移除,导致列表项的部分布局突然顶上去,而看不到这些顶上去的布局(比如上图中 列表项0 就会在临界状态突然顶上去,导致看不到)
问题二:其中一个页面滑动时,会联动其它的页面也滑动相同的距离(比如第一个页面向下拉了3个列表项高度的距离,那么此时向右滑动到第二个界面,会发现第二个页面也向下滑动了3个列表项高度的距离)
首先针对问题一,既然TabLayout
被从原布局中移除时,会导致下方布局被顶上去的问题,那么我可以简单直接的在TabLayout
的地方添加一个同等高度的“占位布局”,当TabLayout
移除时,占位布局来暂时占位,ok,我们现在修改布局代码如下
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.hq.testscrollview.InterceptScrollView
android:id="@+id/interceptScrollView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/container_normal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:descendantFocusability="beforeDescendants"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/top_info"
android:layout_width="match_parent"
android:layout_height="60dp">
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="我是标题栏"
android:textSize="18sp" />
</RelativeLayout>
<android.support.design.widget.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@android:color/white"
app:tabMode="fixed"
app:tabSelectedTextColor="@android:color/black" />
<!-- 占位布局,默认是为gone状态,当TabLayout移除时,才更改其状态为可见,达到“占位”效果-->
<View
android:id="@+id/view_place"
android:layout_width="match_parent"
android:layout_height="50dp"
android:visibility="gone" />
<com.hq.testscrollview.AutofitViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</com.hq.testscrollview.InterceptScrollView>
<!-- 悬浮在屏幕上方的顶部空布局-->
<LinearLayout
android:id="@+id/container_top"
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="vertical">
</LinearLayout>
</RelativeLayout>
相应的ScrollView
的监听方法更改如下
mScrollView.setScrollChangedListener(new InterceptScrollView.ScrollChangedListener() {
@Override
public void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
if (scrollY >= DpUtils.dp2px(MainActivity.this,
60)&&tabLayout.getParent()==container_normal) {
container_normal.removeView(tabLayout);
container_top.addView(tabLayout);
//更改 占位布局为 显示
viewPlace.setVisibility(View.INVISIBLE);
} else if(scrollY < DpUtils.dp2px(MainActivity.this,
60)&&tabLayout.getParent()==container_top){
container_top.removeView(tabLayout);
container_normal.addView(tabLayout, 1);
//隐藏占位布局
viewPlace.setVisibility(View.GONE);
}
}
});
现在运行一下代码,看看效果
ok,现在我们解决了第一个问题,现在来看看第二个问题,各页面滑动的时候会有联动效果。
我们简单分析下,不难找到为什么会发生这样的问题,首先我们滑动ViewPager中的内容时,是通过ScrollView
来拦截的滑动事件,也就是说,虽然内容页看上去分为了三个分页,但是实际上滑动它们的,都是同一个ScrollView
,既然是滑动的同一个ScrollView
,那么自然其中一个页面滑动了多少距离,其它页面也会跟着滑动多少距离。
既然清楚了问题产生的原因,我们可以从根本入手,我这里想到的解决方案是设置一个ArrayMap
,用于保存每个页面分别滑动了多少距离,然后当切换到这个页面的时候,首先从ArrayMap
中取出这个滑动距离,再将ScrollView
滑到对应距离的位置。
ok,有了思路,我们现在来修改代码,首先我们需要思考在哪里存入当前页面滑动的距离,这个因为我们是滑动的ViewPager
,所以自然想到的是监听ViewPager
的滑动,如果滑动导致页面切换,那么存入页面的滑动距离,同时,在切换到新页面时,获取Map
中的值,并将ScrollView
滑动到这个位置,最终Activity
要添加的代码如下
//存放页面和滑动距离的Map
private ArrayMap<Integer,Integer> scrollMap=new ArrayMap<>();
//当前页面
private int currentTab=0;
//当前页面的滑动距离
private int currentScrollY=0;
.....//省略若干代码
//监听viewpager的滑动
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int i, float v, int i1) {
}
@Override
public void onPageSelected(int i) {
mScrollView.scrollTo(0,scrollMap.get(i));
}
@Override
public void onPageScrollStateChanged(int i) {
if(i==1){//手指按下时,记录当前页面
currentTab=viewPager.getCurrentItem();
}else if(i==2){//手指抬起时
if(currentTab!=viewPager.getCurrentItem()){
//如果滑动成功,也就是说翻页成功,那么保存之前页面的滑动距离
scrollMap.put(currentTab,currentScrollY);
}
}
}
});
现在我们来运行看看效果如何:
ok,和我们预期的一样,现在每个分页都是能记录自己的滑动距离,并且各个页面之间互不干扰。
但是很明显,我们又有了新的问题产生,从图中可以很明显的感受到,主要是一些逻辑问题:如果我们在第一个页面滑动让导航栏悬浮在了顶部,然后当我们切换到第二个页面时,因为第二个页面此时滑动距离为0,所以会导致悬浮的导航栏突然回归原位,造成体验极不友好。
那么我们怎么来解决呢?可以增设一个标志位,用于判断导航栏是否悬浮,如果悬浮的话,同时当前页面的滑动距离为0,那么我们将ScrollView
滑动一个固定的距离,让导航栏此时正好悬浮在顶部,在我们这个例子中,这个固定的距离就是标题栏的高度,ok,我们现在来修改代码,看是不是如预期的效果:
//用于判断,当前页面的导航栏是否悬浮
private boolean isTabLayoutSuspend;
........//此处省略代码
mScrollView.setScrollChangedListener(new InterceptScrollView.ScrollChangedListener() {
@Override
public void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
currentScrollY=scrollY;
if (scrollY >= DpUtils.dp2px(MainActivity.this,
60)&&tabLayout.getParent()==container_normal) {
container_normal.removeView(tabLayout);
container_top.addView(tabLayout);
viewPlace.setVisibility(View.INVISIBLE);
isTabLayoutSuspend=true;//记录TabLayout的状态
} else if(scrollY < DpUtils.dp2px(MainActivity.this,
60)&&tabLayout.getParent()==container_top){
container_top.removeView(tabLayout);
container_normal.addView(tabLayout, 1);
viewPlace.setVisibility(View.GONE);
isTabLayoutSuspend=false;//记录TabLayout的状态
}
}
});
........//此处省略代码
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int i, float v, int i1) {
}
@Override
public void onPageSelected(int i) {
//如果导航栏悬浮
if(isTabLayoutSuspend){
//当前页面的滑动距离为0或者小于60dp,那么只需滑动60dp,让导航栏悬浮即可
if(scrollMap.get(i)==0||scrollMap.get(i)<DpUtils.dp2px(MainActivity.this,60)){
mScrollView.scrollTo(0,DpUtils.dp2px(MainActivity.this,60));
}else{//如果页面滑动的距离大于60dp,那么直接滑动对应的距离即可
mScrollView.scrollTo(0,scrollMap.get(i));
}
}else{//如果导航栏没有悬浮
mScrollView.scrollTo(0,currentScrollY);
}
}
@Override
public void onPageScrollStateChanged(int i) {
if(i==1){//手指按下时,记录当前页面
currentTab=viewPager.getCurrentItem();
}else if(i==2){//手指抬起时
if(currentTab!=viewPager.getCurrentItem()){
//如果滑动成功,也就是说翻页成功,那么保存之前页面的滑动距离
scrollMap.put(currentTab,currentScrollY);
}
}
}
});
}
现在来运行看效果,如下
可以看到,和预期的效果一样,TabLayout
跳动的问题已经解决了。
至此,我们已经实现了整体的布局效果+细节实现,对比文章开头京东页面的效果,还有个下拉刷新,RecyclerView
添加下拉刷新,这个很简单,实现方法也多种多样,官网也提供了SwipeRefreshLayout
这个直接使用的下拉刷新控件,至于具体怎么实现,就不是本篇文章的重点了。
2.2 存在的问题
按照上面的方式,可以看到确实是实现了布局效果,但是我们现在修改下每个页面的布局高度,例如我把第一个页面的条目设置为40个,第二个为15个,第三个页面的条为20个,看看有没有什么问题。运行效果如下
可以看到,由于我中间页面只有15个列表项的高度,当我从中间页面滑到第一个页面时,第一个页面并没有如预期的那样滑到我们Map
中保存的的滑动距离的位置,这是因为在第二个页面的时候,ScrollView
的整体最大高度为头部高度+15个列表项的高度,但是回到第一个页面的时候,其列表项数目增多导致超过了这个最大高度,所以ScrollView
只能滑动到这个最大高度(也就是中间页面的高度)。
除此之外,我在使用SwipeRefreshLayout
给RecyclerView
添加下拉刷新时,又遇到了滑动嵌套的问题,因为SwipeRefreshLayout
需要包裹的内容可滑动,同时SwipeRefreshLayout
还会导致RecyclerView
高度显示不全,虽然我们设置了ViewPager
的高度自适应,但是由于Fragment
的最外层被修改为了SwipeRefreshLayout
,所以导致ViewPager
也没办法获取RecyclerView
的高度了,为此,我们可以尝试去重写SwipeRefreshLayout
或者其它方法,来让高度正确显示,或者换种办法,不使用SwipeRefreshLayout
,而直接修改RecyclerView
来实现下拉刷新也行。可见,从一开始如果按照我们的思路(ScrollView+TabLayout+ViewPager+RecyclerView
)来做的话,后面总是会遇到各种各样的问题,这里就不继续深究了。
另外,虽然按照上面这种方法,可以实现效果,但是还有一个最影响性能的问题就是我们不得已设置了ViewPager
一次性缓存所有的页面,显然这是违背ViewPager
的设计初衷的,要知道在APP上,运行卡顿是一件多么糟糕的事情。
如果你一口气跟着我做到这里,虽然我们费了很大的力气,解决了一个问题,又冒出来一个问题,但是我们最后还是遇到了各种各样的问题有待解决,是不是以为这就结束了呢,是不是有点想放弃了呢,其实不然!
车到山前必有路,这篇文章真正的内容才刚刚开始!
3、解决方案
3.1 揭开神秘面纱 ,论知识广度的重要性
在上面,我们费了好大的力气实现的效果,仍然不完美,那到底上面京东页面的效果是怎样实现的呢?
首先我们先抛开之前的思路,也就是使用传统的布局,通过各种嵌套来实现效果。那么我们的问题来了,不使用这些基本的控件和布局,那使用啥呢?
这时,如果平常关注Android
版本更新多一点的话,可能会看到一个相对陌生的词,叫CoordinatorLayout
,这是Google在Android5.0的时候推出的一个新布局,中文翻译过来叫“协调者布局”,在绝大部分的开发工作中,我们可能都不会使用到这个控件,我自己初次看到这个布局的时候,还是比较好奇的,下了一些demo运行了下,感觉还不错,之后就没有再怎么管这个布局了,甚至把它忘了。
这不,谁能知道,这次困扰我这么久的问题,居然让CoordinatorLayout
给一下子解决了,而且代码及其简单,不用像我们上面那样去解决各种高度显示不全、滑动冲突等等诸多问题。
ok,由于本文的重点不是介绍CoordinatorLayout
,所以就不花很大的篇幅来讲解,对这个布局感兴趣的可以深入了解下,当然在使用这个布局之前,记得加上support:design
的依赖库,下面直接放上布局代码,
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:elevation="0dp"
android:background="@android:color/white">
<RelativeLayout
android:id="@+id/title_layout"
android:layout_width="match_parent"
android:layout_height="50dp"
app:layout_scrollFlags="scroll">
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="我是标题栏"
android:textSize="18sp" />
</RelativeLayout>
<android.support.design.widget.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@android:color/white"
app:tabMode="fixed"
app:tabSelectedTextColor="@android:color/black" />
</android.support.design.widget.AppBarLayout>
<android.support.v4.view.ViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</android.support.design.widget.CoordinatorLayout>
OK,然后再在Activity
中添加对应的逻辑,基本上和我们之前探索过程中的代码没有很大差别,如下
public class MainActivity extends AppCompatActivity {
private TabLayout tabLayout;
private ViewPager viewPager;
private FragmentPagerAdapter mPageAdapter;
private ArrayList<String> titleList = new ArrayList<>();
private ArrayList<Fragment> fragmentList=new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initData();
initView();
}
private void initData(){
titleList.clear();
titleList.add("标签一");
titleList.add("标签二");
titleList.add("标签三");
fragmentList.clear();
fragmentList.add(new OneFragment());
fragmentList.add(new TwoFragment());
fragmentList.add(new ThreeFragment());
mPageAdapter=new FragmentPagerAdapter(getSupportFragmentManager()) {
@Override
public Fragment getItem(int i) {
return fragmentList.get(i);
}
@Nullable
@Override
public CharSequence getPageTitle(int position) {
return titleList.get(position);
}
@Override
public int getCount() {
return titleList.size();
}
};
}
private void initView(){
tabLayout=findViewById(R.id.tabLayout);
viewPager=findViewById(R.id.viewPager);
viewPager.setAdapter(mPageAdapter);
tabLayout.setupWithViewPager(viewPager);
}
}
代码中的适配器和Fragment
都是使用的之前的,Fragment
的代码和布局和之前相比就加了一个SwipeRefreshLayout
用于下拉刷新,布局如下
<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
</android.support.v4.widget.SwipeRefreshLayout>
Fragment
代码如下
public class OneFragment extends Fragment {
private RecyclerView mRecyclerView;
private RecyclerView.LayoutManager mLayoutManager;
private RecyclerViewAdapter mAdapter;
private SwipeRefreshLayout swipeRefreshLayout;
private View mainView;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mainView = inflater.inflate(R.layout.fragment_one, container, false);
initView();
return mainView;
}
private void initView() {
mRecyclerView = mainView.findViewById(R.id.recyclerview);
swipeRefreshLayout=mainView.findViewById(R.id.swipeRefreshLayout);
mLayoutManager = new LinearLayoutManager(getActivity(),
LinearLayoutManager.VERTICAL, false);
ArrayList<String> data = new ArrayList<>();
for (int i = 0; i < 40; i++) {
data.add("列表项" + i);
}
mAdapter = new RecyclerViewAdapter(getActivity(), data);
mRecyclerView.setLayoutManager(mLayoutManager);
mRecyclerView.setAdapter(mAdapter);
swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh() {
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
swipeRefreshLayout.setRefreshing(false);
Toast.makeText(getActivity(),"刷新完成",Toast.LENGTH_SHORT).show();
}
},2000);
}
});
}
}
现在我们来运行一下看看效果怎么样,如下所示:
怎么样,没有了布局的嵌套,使用CoordinatorLayout
居然实现起来这么简单,简直神奇!而且之前我们实现的方案,最后遗留的问题也都没有,运行很流畅。
到这里,我不得不感叹下,知识的广度确实挺重要的,见的多了,走的弯路也就少了。
3.2 简单学习下CoordinatorLayout(协调者布局)
在最后我们成功使用CoordinatorLayout
解决了我们的问题,但是我们难免会好奇,CoordinatorLayout
究竟是怎样实现这么复杂的效果的,答案就是它的核心Behavior
,这个就留作另一篇文章把,今天我们主要学习下CoordinatorLayout
相关的概念。
在上面最后实现的布局中,我们难免会有些疑问。可以看到,在这个布局中,我们看到了一个相对陌生的属性layout_scrollFlags
,这个属性设置在了标题栏布局上,我们首先来学习下这个属性。从资料中,我们可以知道,这个属性用于在滑动时,执行怎样的操作,该属性一共有五个值,具体的含义如下
- scroll : 该View伴随着滚动事件而滚出或滚进屏幕。
- enterAlways : 快速返回模式,向下滚动时,首先将该View滚动出来,然后再滚动整体布局。
- enterAlwaysCollapsed : 折叠快速返回模式,向下滚动时,该View先向下滚动最小高度值,然后整体布局开始滚动,到达边界时,该View再向下滚动,直至显示完全。
- exitUntilCollapsed : 向上滚动事件时,该View向上滚动退出直至最小高度,然后整体布局开始滚动。最后的状态是该View不会完全退出屏幕。
- snap : 该View不会存在局部显示的情况,滚动该View的部分高度,当我们松开手指时,该View要么向上全部滚出屏幕,要么向下全部滚进屏幕。
我们这里根据需求,很显然需要选择scroll
这个值,因为每次我们滑动时,都需要该View滑出屏幕。
当然,可能还有一个疑问,为什么一定要使用AppBarLayout
这个控件呢,这是因为layout_scrollFlags
这个属性是AppBarLayout
这个控件才有的。
然后我们可以看到ViewPager
写的位置非常奇怪,为什么要写在这里呢,这个,其实是官网推荐的写法,相当于在CoordinatorLayout
布局中的一种固定用法,可以这么理解。
然后我们还看到了另外一个陌生的属性layout_behavior
,这个属性是干嘛的呢,这个属性有很多值,其实就是一个字符串,而这个字符串的作用就是把当前View放到AppBarLayout
控件的下面。
结语
本篇文章到此就差不多了,本以为很快就可以写完的,结果写了这么久,写这篇文章的目的也很简单,就是如果需要实现类似我这样的效果,大家就不要像我一样走弯路了,各种嵌套各种冲突,解决了一个问题又俩一个问题,结果最后还是有问题没法解决,性能也不好,下次遇到复杂的交互布局时,就不要想到使用嵌套了,而是CoordinatorLayout
。
然后文章中关于CoordinatorLayout
的内容比较少,我自己对这个布局也不是很熟,下一篇,再深入了解下这个神奇好玩的布局。
最后放上本篇中两个例子的源码供参考,按照我自己最初的思路,使用基本控件嵌套实现的布局:点此下载;使用CoordinatorLayout
实现布局的源码:点此下载。