Android 两种实现ScrollView吸顶效果的方法

1,613 阅读1分钟

一、前言

ScrollView 吸顶是很常见的用法,之前用过 StickyScrollView,存在的问题是只是把 View 图像定位到了顶部,无法处理 touch event,同时也无法阻止事件穿透等。

这里我们提供一种相对简单的 可吸顶View 组件。

1.1 效果预览

下面是本篇要实现的效果,本篇将实现两种可吸顶的ScrollView,第一种是可以兼容Android 4.4 之前的版本,利用的是Bounds变化,稳定性和性能一般,第二种是利用的绘制坐标的变化,稳定性和性能最好,但不兼容Android 4.4 之前的版本。

1.2 两种滑动方式

我们要实现ScrollView吸顶有个严重的问题是,要吸顶的Item不是ScrollView的直接子View,因此,在本篇阅读之前,我们要了解下Android的两种滑动方式:

  • 第一种是布局位置滑动,主要通过调整left、top、right、bottom实现
  • 第二种是图像位置滑动,主要通过调整View图像坐标x,y来实现

相比而言,如果在ItemView有限的情况下,第二种性能更好,因为第二种可以在不触发requestLayout的情况下实现视图更新, 但是在ItemView多的话,第一种性能会超过第二种。比如RecyclerView就使用的第二种,而ScrollView使用的是第一种。

1.3 通用公式

当然,我们要知道一个重要的公式,其实正常情况下x = left +  translationX,y = top + translationY ,但有意思的是left和top变化会触发requestLayout,而translation是不会触发的。

X = left + translationX # (默认情况下 translationX为0)

Y = top + translationY  # (默认情况下 translationY为0)

二、方案选择

本篇我们两种方案都会涉及,对于ScrollView而言,哪种方案更好呢?

实际上是图像位置滑动方案更好,但是这个方案有个明显的要求是Z轴支持,因此需要Android 5.0+的版本,但是如果是Android 4.4 及以前的版本就无法使用了。因此,如果考虑兼容Android 4.4及之前的版本,这里只能使用第一种方案

三、布局位置滑动方案

3.1 重点方法

  • View#layout 方法 :调用此方法调整View在布局中的位置
  • View#bringToFront 方法: 调用此方法会改变 View 在布局中的顺序

为什么会使用到这两个方法呢?

首先ScrollView->LinearLayout中的View一旦布局完成,那么绘制顺序就是固定的,在Android中,后加入的View默认情况下展示层级高于前面加入的,我们要解决的是,分组ItemView无法动态变更顺序的问题,其次分组ItemView吸顶后分组ItemView被普通ItemView遮住的问题。

3.1 requestLayout抑制问题

问题是View#bringToFront本身带有一定的副作用,其最终调用会调用到ViewGroup#bringChildToFront的方法,该方法会频繁requestLayout

    @Override
    public void bringChildToFront(View child) {
        final int index = indexOfChild(child);
        if (index >= 0) {
            removeFromArray(index);
            addInArray(child, mChildrenCount);
            child.mParent = this;
            requestLayout();
            invalidate();
        }
    }

还有没有其他方法?

我们使用View#bringToFront是为了变更顺序,但实际上,我们真正开发中应该尽可能避免变更顺序,然而,这里我们主动变更顺序的原因是,其他可以抑制requestLayout的方法都是protected修饰的,我们知道在ScrollView中,任何一个ItemView都是ScrollView的“孙子”,因此如果想用下面的方法,就得复写ScrollView的直接子View。

下面是可以抑制requestLayout的方法

detachViewFromParent(View)

attachViewToParent(View,int,LayoutParam)

或者

removeViewInLayout(View)

addViewInLayout(View,int,LayoutParam)

上面两种比较灵活,一般情况下推荐第一种,因为第二种的保护性太强,一般情况下需要addViewInLayout在外部是无法调用的。

但是这里我们为了改动更小,也不使用上面的方法,为什么呢?主要还是ScrollView需要跨过“子View”去操作“孙View”,显然不方便呐。

调整绘制顺序

实际上还有一种最优的方法,那就是实现ViewGroup方法,也就是复写本篇的LinearLayout,这个不会修改View在布局中的顺序,只会改变绘制顺序,也不会触发requestLayout,是一种最优的方案,但是鉴于自定义布局应该尽可能简单,也不使用这种方法。

  protected int getChildDrawingOrder(int childCount, int drawingPosition) {
        return drawingPosition;
    }

显然,这里也面临跨国“子View”操作“孙View”的问题,因此,你不仅仅需要自定义ScrollView,还需要自定义ScrollView的“直接子View”,当然也不是不可行,作为性能优化的手段还是可以的。后续有时间的话,我们也会补上这类实现。

最终方法

最终我们还是选择bringToFront方法,不过也会做相应的优化

3.2 吸顶View标记

首先我们利用View的tag,如果要吸顶,那么其tag标记为sticky,实际上也只能这么做,因为我们无法通过LayoutParams去影响“孙子”节点的View。

3.3 记录原始高度偏移

我们定义一个类,用来记录原始的高度

 static class StickyView {
        int srcTop;  //原始位置
        View widget; //可以吸顶的View

        public StickyView(View child) {
            this.widget = child;
            this.srcTop = child.getTop();
        }
    }

3.4 滚动监听

为了减少耦合问题,这里我们复写View下面的方法

protected void onScrollChanged(int sl, int st, int oldsl, int oldst) {
}
  • sl 对应scroll left
  • st 对应scroll top
  • oldsl 对应 old scroll left
  • oldst 对应 old scroll top

这里,我们主要关注 scroll top,通过scroll top计算出吸顶View,以及吸顶View所需要的偏移值。

3.5 核心逻辑

View偏移的核心方法定义,首先要保证childView.layout的topOffset值为滚动高度,第三个参数 bringToFront用来控制方法调用,不需要吸顶的View不需要变更顺序。同样,我们判断view在布局中的顺序,防止View无意义的调用。

    private void refreshTopOffset(View childView, float offset, boolean bringToFront) {
        int topOffset = (int) (offset);
        childView.layout(childView.getLeft(), topOffset, childView.getRight(), topOffset + childView.getHeight());
        if (!bringToFront) {
            return;
        }
        ViewGroup parent = (ViewGroup) childView.getParent();
        if(parent == null){
            return;
        }
        int childCount = parent.getChildCount();
        if (parent.indexOfChild(childView) != (childCount - 1)) {
            childView.bringToFront(); //解决被其他view遮挡问题
        }
    }

下面是滚动的核心方法,在滚动前重置所有sticky View的位置,然后找打最大可以偏移的View,对齐进行吸顶操作。

    @Override
    protected void onScrollChanged(int sl, int st, int oldsl, int oldst) {
        super.onScrollChanged(sl, st, oldsl, oldst);
        if (mStickyView == null) {
            mStickyView = new ArrayList<>();
        }
        int stickyChildCount = mStickyView.size();

        if(stickyChildCount == 0){
            return;
        }

        int stickyIndex = -1;
        for (int i = 0; i < stickyChildCount; i++) {
            StickyView childView = mStickyView.get(i);
            refreshTopOffset(childView.widget, childView.srcTop, false); 
//重置所有view
            int offset = childView.srcTop - st;  //原始高度减去滚动的高度
            if (offset < 0) { 
                stickyIndex = i;    // 记录索引最大的可偏移View
            }
        }
        if (stickyIndex < 0) {
            return;
        }

        StickyView childView = mStickyView.get(stickyIndex);
        int nextChildIndex = stickyIndex + 1;
        if (nextChildIndex > mStickyView.size() - 1) {
            //只有一个需要吸顶
            refreshTopOffset(childView.widget, st, true);
        } else {
            StickyView nextView = mStickyView.get(nextChildIndex);
            int nextChildOffset = nextView.srcTop - st;
            if (nextChildOffset > childView.widget.getHeight()) {
                //将筛选出的View直接吸顶
                refreshTopOffset(childView.widget, st, true);

            } else {
                //存在两个sticky itemView 挤压情况,进行偏移
                float dx = childView.widget.getHeight() - nextChildOffset;
                refreshTopOffset(childView.widget, st - dx, false);
            }
        }
    }

3.6 requestLayout 抑制优化

为了防止onLayout无效的调用,我们这里一定要判断changed是为true,防止不必要的布局逻辑

@Override
protected  void onLayout(boolean changed, int l, int t, int r, int b) {    
   if (!changed) return;
super.onLayout(changed, l, t, r, b);
// 省略
}

但是这里仍然有掩耳盗铃的嫌疑,如果出现多层嵌套,还是有一定的影响,我们参考RecyclerView的方法,在调用bringToFront前进行标记,组织requestLayout传递。

  @Override
    public void requestLayout() {
        if(!shouldRequestLayout){
            shouldRequestLayout = true;
            return;
        }
        super.requestLayout();
 }

3.7 获取所有可吸顶的View

这里我们在onLayout中进行,因为这种情况下View的稳定性较高,可减少View变化的问题。这里我们主要通过View的tag来实现,判断其是不是sticky值,如果是的话加入列表中。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (!changed) return;
        super.onLayout(changed, l, t, r, b);

        if (mStickyView == null) {
            mStickyView = new ArrayList<>();
        } else {
            mStickyView.clear();
        }
        int realChildCount = getRealChildCount();
        ViewGroup parent = (ViewGroup) getChildAt(0);

        for (int i = 0; i < realChildCount; i++) {
            View child = parent.getChildAt(i);
            Object tag = child.getTag();
            if (!(tag instanceof CharSequence)) continue;

            if ("sticky".equals(tag)) { 
                mStickyView.add(new StickyView(child));
            }
        }

    }

3.8 完整代码

本篇这里提供完整代码,整个实现过程其实就是利用View#layout方法进行偏移,还有就是可以使用offsetXXX也可以达到一样的效果。本篇另一个重要的知识点是requestLayout 抑制,当然,在RecyclerView中,requestLayout的抑制范围更大,如果想学习自定义View,也可以参考RecyclerView中的相关逻辑。

public class FloatScrollView extends ScrollView {
    private List<StickyView> mStickyView = null;
    private boolean shouldRequestLayout = true;
    public FloatScrollView(Context context) {
        this(context, null);
    }
    public FloatScrollView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public FloatScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
     int getRealChildCount() {
        int childCount = getChildCount();
        if (childCount == 0) return 0;
        ViewGroup wrapperView = (ViewGroup) getChildAt(0);
        if (wrapperView == null) return 0;

        return wrapperView.getChildCount();
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (!changed) return;
        super.onLayout(changed, l, t, r, b);

        if (mStickyView == null) {
            mStickyView = new ArrayList<>();
        } else {
            mStickyView.clear();
        }
        int realChildCount = getRealChildCount();
        ViewGroup parent = (ViewGroup) getChildAt(0);

        for (int i = 0; i < realChildCount; i++) {
            View child = parent.getChildAt(i);
            Object tag = child.getTag();
            if (!(tag instanceof CharSequence)) continue;
            //记录吸顶View
            if ("sticky".equals(tag)) {
                mStickyView.add(new StickyView(child, i));
            }
        }

    }

    @Override
    protected void onScrollChanged(int sl, int st, int oldsl, int oldst) {
        super.onScrollChanged(sl, st, oldsl, oldst);
        if (mStickyView == null) {
            mStickyView = new ArrayList<>();
        }
        int stickyChildCount = mStickyView.size();

        if (stickyChildCount == 0) {
            return;
        }

        int stickyIndex = -1;
        for (int i = 0; i < stickyChildCount; i++) {
            StickyView childView = mStickyView.get(i);
            refreshTopOffset(childView.widget, childView.srcTop, false);
            int offset = childView.srcTop - st;
            if (offset < 0) {
                stickyIndex = i;
            }
        }
        if (stickyIndex < 0) {
            return;
        }

        StickyView childView = mStickyView.get(stickyIndex);
        int nextChildIndex = stickyIndex + 1;
        if (nextChildIndex > mStickyView.size() - 1) {
            //只有一个需要吸顶
            refreshTopOffset(childView.widget, st, true);
        } else {
            StickyView nextView = mStickyView.get(nextChildIndex);
            int nextChildOffset = nextView.srcTop - st;
            if (nextChildOffset > childView.widget.getHeight()) {
                refreshTopOffset(childView.widget, st, true);
            } else {
                float dx = childView.widget.getHeight() - nextChildOffset;
                refreshTopOffset(childView.widget, st - dx, false);
            }
        }
    }

    private void refreshTopOffset(View childView, float offset, boolean bringToFront) {
        int topOffset = (int) (offset);
        childView.layout(childView.getLeft(), topOffset, childView.getRight(), topOffset + childView.getHeight());
        if (!bringToFront) {
            return;
        }
        ViewGroup parent = (ViewGroup) childView.getParent();
        if (parent == null) {
            return;
        }
        int childCount = parent.getChildCount();
        if (parent.indexOfChild(childView) != (childCount - 1)) {
            shouldRequestLayout = false;
            childView.bringToFront(); //解决被其他view遮挡问题
        }
    }

    @Override
    public void requestLayout() {
        if (!shouldRequestLayout) {
            shouldRequestLayout = true;
            return;
        }
        super.requestLayout();
    }
    static class StickyView {
    
        int srcTop;
        View widget;

        public StickyView(View child) {
            this.widget = child;
            this.srcTop = child.getTop();
           
        }
    }
}

通过上述逻辑就实现了吸顶效果,那还有没有其他更好的方案?

四、图像位置滑动方案

4.1 setY或者setTranslationY

View位置滑动方案能满足大部分需求,但是也有个明显的问题,那就是可吸顶的View的顺序是变化的,这个显然不是你想要的,那还有没有优化方向呢?

我们知道ScrollView本身也是设置【子View】的translatonY或者Y值来移动View的画面。

至于View被遮住问题,可以通过View#setZ来解决,但是View#setZ对5.0之前的版本不支持,因此如果不考虑兼容4.x版本,完全可以使用此方法来优化。

4.2 requestLayout抑制

这种方案相理论上可以有效抑制requestLayout,还可以完全避免View顺序不一致的问题,总之,使用哪种方案取决于你的项目需求。

4.3 实现流程

  • 重置X,Y 在默认位置和默认Z轴
  • 选择最接近顶部的吸顶View进行吸顶
  • 计算与下个吸顶View的交叉距离,进行偏移

注意:对于Z轴的还原我们这里设置为0,但是有特殊需求建议适当调整代码逻辑,防止遮挡

4.4 新方案完整代码

下面是完整优化代码,可以看到,相比bringToFront + layout方案,这种代码量更小,灵活度度更高,但是缺点是Android 4.x 不支持z属性设置

public class FloatScrollViewV2 extends ScrollView {
    private List<View> mStickyView = new ArrayList<>();

    public FloatScrollViewV2(Context context) {
        this(context, null);
    }
    public FloatScrollViewV2(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public FloatScrollViewV2(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
    int getRealChildCount() {
        int childCount = getChildCount();
        if (childCount == 0) return 0;
        ViewGroup wrapperView = (ViewGroup) getChildAt(0);
        if (wrapperView == null) return 0;

        return wrapperView.getChildCount();
    }

    View getRealChild(int index) {
        int childCount = getChildCount();
        if (childCount == 0) return null;
        ViewGroup wrapperView = (ViewGroup) getChildAt(0);
        if (wrapperView == null) return null;
        return wrapperView.getChildAt(index);
    }

    @Override
    protected void onScrollChanged(int sl, int st, int oldsl, int oldst) {
        super.onScrollChanged(sl, st, oldsl, oldst);
        int realChildCount = getRealChildCount();
        if (realChildCount <= 0) return;
        int stickyIndex = -1;
        mStickyView.clear();
        for (int i = 0; i < realChildCount; i++) {
            View childView = getRealChild(i);
            Object tag = childView.getTag();
            if (!(tag instanceof CharSequence)) continue;
            if(!"sticky".equals(tag)){
               continue;
            }
           //先还原一下所有Sticky View 状态
            childView.setTranslationY(0); 
            childView.setZ(i);
            int offset = childView.getTop() - st;
            if (offset < 0) {
                stickyIndex = i;
            }
            mStickyView.add(childView); //记录一下
        }
        if(stickyIndex < 0){
            return;
        }

        View childView = getRealChild(stickyIndex);
        int nextChildIndex = stickyIndex + 1;

        if (nextChildIndex > mStickyView.size() - 1) {
            //只有一个需要吸顶
            childView.setTranslationY(st - childView.getTop());
        } else {
           //查找下一个Sticky View
            int index = mStickyView.indexOf(childView);
            View nextView = mStickyView.get(index + 1);

            int nextChildOffset = nextView.getTop() - st;
            if (nextChildOffset > childView.getHeight()) {
                childView.setTranslationY(st - childView.getTop());
            } else {
                float dx = childView.getHeight() - nextChildOffset;
                childView.setTranslationY(st - dx - childView.getTop());
            }
        }     //确保不被遮住
      childView.setZ(realChildCount + stickyIndex);   
 }

}

五、总结

本篇,我们实现了两种实现ScrollView吸顶的实现方法。

第一种核心方法是layout和bringToFront,属于子View自身的偏移位置修改,因此,这个过程会产生requestLayout,相对于一般View,使用ScrollView的页面一般不怎么复杂,还是可以使用的,和我们之前的几篇文章类似,可以了解下。

第二种是优化方法,也是本篇比较推荐的,相比layout和bringToFront,这里使用RenderNode 的Matrix变换,主要是X和Z变量,偶合度更低,灵活性更好,还能更好的抑制requestLayout。当然,缺点是不支持Android 4.4 之前设备。

另外,我们实现的是垂直滑动,如果是水平的也可以参考本篇实现。

以上是两种方案,至于选择哪种方案,取决于项目情况吧。