Android吸顶效果——Smartrefresh+ScrollView+ViewPager+Fragment+RecylerView(NestedScrollView)

###0.前言
吸顶效果图

###1.实现思路

  1. 布局的嵌套层级为:
    • FrameLayout
      • SmartRefreshLayout
        • ScrollView
          • LinearLayout
            • HeaderView
            • TabLayout
            • ViewPager


  1. 显示的默认状态和吸顶状态图
    吸顶效果思路图

实现思路:

第一步:吸顶效果

  • 吸顶标题栏高度+内容页高度+状态栏高度=屏幕高度时,可以达到吸顶效果。

通俗的解释就是ScrollView滑动到底部且再也不能滑动了,此时,吸顶标题栏刚好处于屏幕最上方。

怎么实现呢?

  • 就是通过动态计算内容页的高度:内容页高度 = 屏幕高度 - 状态栏高度 - 吸顶标题栏高度



第二步:吸顶后的内容页滑动

  • 明确什么时候滑动外部的ScrollView,什么时候滑动内容页中的滑动控件(如RecycleView、NestedScrollView)



本篇例子的滑动分配思路如下:

  • 当内容页未达到最顶部(系统状态栏高度+吸顶标题栏高度)时,滑动事件由顶层的ScrollView处理
  • 当内容页到达最顶部时,滑动事件分配给内容页内部处理
  • 内容页内部处理滑动事件分两种状况
    • 正常上下滑动(内部自消化)
    • 滑动到顶部后继续向上滑动(无法消化,重新交给顶层ScrollView)

以上就是实现吸顶效果的全部思路。

###2.Smartrefresh+ScrollView+ViewPager+Fragment+RecylerView实现示例

第一步思路的核心实现为内容页,即此示例的ViewPager

ViewPager计算高度的核心方法:

//MatchViewPager.java
private int calcHeight(){
        ViewGroup parent = (ViewGroup) getParent();
        int height = DeviceUtil.getDeviceHeight(getContext()) - mStatusBarHeight;
        if(parent != null && mTargetId  > 0){
            View targetView = parent.findViewById(mTargetId);
            if(targetView != null){
                mTargetViewHeight = targetView.getHeight();
                if(mTargetViewHeight <= 0){
                    mTargetViewHeight =  ViewUtil.getViewHeight(targetView);
                }
                height = height - mTargetViewHeight;
            }
        }
        height = height - mRemoveHeight;//扣除偏移量
        return height;
    }
  • mStatusBarHeight:状态栏高度
  • mTargetId:标记控件的ID,此处为吸顶标题栏
  • mRemoveHeight:需要额外移除的高度(例如:吸顶标题栏上方可能存在Toolbar,此处可以设置Toolbar的高度进行移除)

1.ViewPager计算高度

  • 方案一:重写onMeasure进行测量

    //MatchViewPager.java
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int height = calcHeight() + 1;//滑动判断需要,事件处理时讲解
        heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
    
  • 方案二:设置viewpager的LayoutParams

    //MatchViewPager.java
    private void initHeight(){ //在自定义控件的构造器中调用
        post(new Runnable() {
            @Override
            public void run() {
                ViewGroup.LayoutParams params = getLayoutParams();
                params.height = calcHeight()+1;//滑动判断需要,事件处理时讲解
                setLayoutParams(params);
            }
        });
    }
    

其中,方案一在onMeasure进行测量时,需要获取targetView(吸顶标题栏)的高度,所以会通过view.measure(childWidthSpec, childHeightSpec);计算一次控件宽高。但因为获取的是view.getMeasuredHeight()所以与实际显示时存在一定偏差。此处使用TabLayout表现出来的效果是:TabLayout的子控件全部居左且不等分布局宽度,滑动布局后恢复正常。

而方案二因为是通过post()异步设置ViewPager高度,所以不存在此问题。

2.事件的分发处理

步骤一:自定义顶层ScrollView,处理什么时候分配给内容页处理

核心是重写onInterceptTouchEvent(MotionEvent ev),在此方法中进行拦截:

 //DetailScrollView.java
 public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getAction();
    if(action == MotionEvent.ACTION_DOWN){//按下
        downY = ev.getRawY();
    }else if(action == MotionEvent.ACTION_MOVE){//移动
        float dy = ev.getRawY() - downY;
        //1.判断上下滑动
        //2.通过mIsInterceptTouchEvent将是否中断交给外部实现
        if(Math.abs(dy) > mTouchSlop &&  mIsInterceptTouchEvent){//是否中断事件向下分发
            return true;
        }else{
            if(mScrollStateListener != null){
                //3.页面滑向顶部时,子页面不能处理则交给父控件
                if(dy > 0 && !mScrollStateListener.isChildTouchEvent()){//手指向下滑(即页面滑向顶部)
                    return true;
                }
            }
        }
    }
    return super.onInterceptTouchEvent(ev);
}

public void setInterceptTouchEvent(boolean isInterceptTouchEvent) {
    this.mIsInterceptTouchEvent = isInterceptTouchEvent;
}
  • mTouchSlop:configuration.getScaledTouchSlop();//移动距离要大于这个距离才开始移动控件
  • mIsInterceptTouchEvent:变量,提供外部动态设置是否拦截事件
  • mScrollStateListener:监听子控件触摸事件消化情况

外部实现拦截:

//MainActivity.java
 private void initScrollView(){
    mSvFirst.setInterceptTouchEvent(true);//设置默认为ScrollView滑动
    mSvFirst.setDetailScrollStateListener(this);//设置滑动状态监听
    mSvFirst.setScrollViewListener(this);//ScrollView滑动是否分发给子控件
 }

 @Override
 public void updateTouchEvent(boolean isInterceptTouchEvent) {
     if(mSvFirst != null)
         mSvFirst.setInterceptTouchEvent(isInterceptTouchEvent);
 }

 @Override
 public void onScrollChanged(ObservableScrollView scrollView, int x, int y, int oldx, int oldy) {
     int[] location = new int[2];
     mVpMain.getLocationOnScreen(location);
     int yPosition = location[1];
     if (yPosition < mVpMain.getHeightOffset()) {//移动到顶部,小于顶部偏移量(ViewPager完全显示)
         updateTouchEvent(false);
     } else {
         updateTouchEvent(true);
     }
 }

通过ScrollView的滑动监听onScrollChanged(...),监听子控件ViewPager当前的Y轴坐标是否达到最顶部去(即ViewPager完全显示),
当ViewPager完全显示则通知ScrollView不要拦截事件。

calcHeight() + 1的问题:
由于滑动是否拦截的判断条件为yPosition < mVpMain.getHeightOffset(),yPosition能达到的临界值恒大于等于mVpMain.getHeightOffset(),所以条件恒为false。如果不写calcHeight() + 1那么可以将条件改为:yPosition <= mVpMain.getHeightOffset()(加上等于判断)

步骤二:子控件是否处理滑动事件

核心在于将子控件的事件处理状况通知ScrollView,即实现mScrollStateListener.isChildTouchEvent()方法。代码如下:

//MainActivity.java
@Override
public boolean isChildTouchEvent() {
    Fragment fragment =  mFragments.get(mVpMain.getCurrentItem());
    if(fragment instanceof BaseDetailFrag){
        return ((BaseDetailFrag)fragment).canChildTouch();
    }
    return false;
}

BaseDetailFrag类集成子控件的滑动监听实现:

//BaseDetailFrag.java
protected void updateCanTouch(boolean canTouch){
       this.canTouch = canTouch;
   }
   public boolean canChildTouch() {
       return canTouch;
   }
   protected RecyclerView.OnScrollListener getOnScrollListener(){
       return new RecyclerView.OnScrollListener() {
           @Override
           public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
               super.onScrolled(recyclerView, dx, dy);
               if(!recyclerView.canScrollVertically(-1)){//在顶部,且当前View已经允许可滑动
                   if(dy < 0){//向上滑,应该直接滑动父容器
                       updateCanTouch(false);
                   }else{//向下滑,直接滑动
                       updateCanTouch(true);
                   }
               }else{
                   updateCanTouch(true);
               }
           }
       };
   }

Fragment实现类中的使用:

ListFragment.java
private void initRecyclerView(){
  //...
  //设置RecyclerView的滑动监听,在基类中已有默认方法
  recyclerView.addOnScrollListener(getOnScrollListener());
}

NestedScrollView.java
private void initView(View rootView){
  //设置NestedScrollView的滑动监听,在基类中已有默认方法
  mScrollView.setOnScrollChangeListener(getNestedScrollListener());
}

###3.总结

  • Android手机的兼容问题,部分手机的naigationbar是可以隐藏的,需要注意!
  • 沉浸式的时候需要注意在onScrollChanged(…)中对吸顶的标题栏进行处理
    • 处理方案一:动态扩展吸顶状态栏高度(paddingTop)
    • 处理方案二:一直隐藏一个吸顶状态栏在顶部,到达顶部时显示,否则隐藏(很粗暴但很实用)
  • 仿美团的条件筛选栏+recylerView的也可以通过此方式实现:(查看示例代码)
    • MatchViewPager -> MatchFrameLayout
    • 不使用BaseDetailFrag,直接为本页面RecycleView添加滑动监听

###4.附

示例代码源码路径

END

–Nowy

–2019.02.19

分享到