一、前言
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 之前设备。
另外,我们实现的是垂直滑动,如果是水平的也可以参考本篇实现。
以上是两种方案,至于选择哪种方案,取决于项目情况吧。