前言
在之前的一些文章中,我们实现过各种各样的布局效果,当然也有吸顶效果,在写本篇之前可以看看之前的文章。
- 《Android RecyclerView多Tab列表吸顶效果》
- 《Android ScrollView 吸顶效果》
- 《Android NestedScrolling 联动机制为 RecyclerView 添加 Header》
- 《Android 地图上滑View实现》
上面的文章基本都是View内部布局方式实现的,当然也有Scrolling机制、ViewDragger、内部事件等。其实,按照Android官方的意图,从约束布局和RecyclerView上看,其目标是减少对View内部的实质性修改,而通过布局辅助器增强View的功能,因为不断的自定义View对使用者的学习成本比较高,甚至有很多人都疲倦去学习新的View用法,新View涉及导包、api、布局等,经常要学习,久而久之利用率显然不太理想。而对于开发者比较熟悉的View上进行扩展,但又能让开发者快速接入,显然LayoutManager或者各种Helper方式显然效果更好一些。
下面是本篇的效果
Layout自定义知识点回顾
其实自定义Layout重点在测量、布局、绘制、事件处理,这里其实大家耳熟能详了。
基本知识
- 测量:测量子View或者自身View的大小,由外到内测量,测量有三种模式,但父View可以决定子View的模式。
- 布局:布置子View或者自身View的位置,由外到内测量
- 绘制:将View的图形描述绘制到Canvas上
- 事件:一般指Touch事件和Key事件,前者在触屏模式使用,后者在焦点模式使用 (注意:我这里说的模式,而不是设备,因为Android设备这两种都支持)
我们着重了解下事件,因为是老生常谈的事情。
事件拦截:
- 捕获事件必须接受DOWN事件
- KEY_EVENT可以直达焦点View,而Touch事件需要层层传递
- 同一ViewGroup的子View中,默认情况下,绘制顺序越靠后,越容易先接收到事件,因为绘制靠后的View是后续加入的,层级较高。
- 在事件传递的过程中,事件传递过程中ViewGroup至少有2次以上的拦截机会。
- KEY_CENTER\KEY_ENTER 等部分事件会被判定长按,其他事件会被判断为多次点击
- onClick和onLongClick是通过定时触发的
- hotspot 可以让drawable接收到事件
- 事件接受时间是不连续的
- EventHub负责接收手机,通过InputChannel向前台Activity传递事件
- Window接收事件的顺序是在Activity之后 ....
requestLayout抑制
- 不要修改布局边界,多用Matrix去处理,如scale、rotate、translate等
- 按照显示隐藏频度,高频使用INVSIBLE & VISIBLE
- 设置drawable之前提前设置drawable大小,避免setBackground内部触发requestLayout
- TextView固定大小或者自定义文本展示,避免requestLayout
- 进度类型,不要修改布局边界,建议修改drawable的边界
- 减少布局层级,降低requestLayout measure的几率
- 减少addView、removeView、offsetXXX方法的调用,适当使用removeViewInLayout或者addViewInLayout,当然addViewInLayout外部无法调用,那就使用detachViewFromParent和attachViewFromParent。
建议
避免过多的LayoutInflater,提高可移植性 尽可能减少requestLayout,提高绘制帧率 高帧率异步渲染、必要时使用SurfaceView 尽可能使用Adapter实现View的复用 减少主线程耗时 ...
吸顶效果原理
目前,网上有两种主流的实现方案:
利用ItemDecoration绘制
这种有个比较明显的缺陷就是点击事件很难响应,因为绘制区域无法拦截事件
父View Wrapper
这种是利用父View,从Recycler缓存中拿一个和RecyclerView相同类型的View,可以处理事件,但是由于和RecyclerView上的Item是相互独立的因此需要进行状态同步,比如在RecyclerView上的是CheckBox,那么显然需要LiveData或者EventBus去处理,这样耦合逻辑会很多。
自定义LayoutManager
我们这里不是继承LayoutManager,因为毕竟RecyclerView原始逻辑很成熟,我们只需要继承LinearLayoutManager或者GridLayoutManager。
自定义LayoutManager的开源项目中你很难看到对这两者的扩展,毕竟实在是太复杂了。
LinearLayoutManager和GridLayoutManager的布局思想
LayoutManager只初始化布局和布局item滑动时填充。
关于滑动
我们之前很多自定义Layout的文章中提到过,在Android中View的滑动方式有两种:
- 第一种是"齿轮传动",核心原理是Matrix 变换 (x,y,scale),代表View是ScrollView,当然这种性能很高,但是在View变多时性能会显著下降;
- 另一种是滑板派,所有子View的布局边界联动(left、right、top、bottom),单一操作性能一般,但是配合Adapter不断复用回收,相比ScrollView在大量View的情况下性能显然高很多。
关于填充
由于要配合Recycler机制,LayoutManager需要不断回收和复用View,但是重点是其填充逻辑。
填充逻辑
LinearLayoutManager的填充逻辑是
- 尝试移除View并回收
- 查找锚点(默认取第一个)
- 然后执行三种layout steps
- 布局完成
为什么很少有LinearLayoutManager的吸顶,主要是锚点问题,好消息是onAnchorReady这个方法是可以修改锚点的,换消息是只对包内子View开放,所以你需要在androidx.recyclerview.widget下继承。
当然,本篇没有这么做,因为还是太复杂。
本篇主要分为三步:
- 釜底抽薪,不让吸顶View成为锚点
- 执行父类方法
- 重新布置吸顶View的位置
下面是核心过程
核心思想
釜底抽薪
首先,我们要解决的是如何避免要吸顶的View不被选择为锚点?因为一旦选择为锚点,那么其他子View会参考锚点位置布局,所以,要在LayoutManager选择锚点前"无刷新移除"View,这里我们可以使用removeAndRecycleView。
这招可以称为"釜底抽薪"
这里我们只需要在布局之前将锚点移除
java
//先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
super.onLayoutChildren(recycler, state);
同样纵向也是
java
//先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
int scrollOffsetY = super.scrollVerticallyBy(dy, recycler, state);
删除可见View
删除怎么删呢,怎么知道哪些要被删除呢,其实我们这里需要定义ItemViewType,和Adapter中的itemViewType映射。
ini
private int[] stickyItemTypes = null;
删除的时候,不是从缓存中拿View,而是删除上一次在界面上存在的View,当然,我们要删的是吸顶的View和移出视觉区域的View,而不是所有的见面上的Sticky View。
java
/**
* 删除正在吸顶的View
* @param recycler
*/
private void removeStickyView(RecyclerView.Recycler recycler) {
int count = getChildCount();
if (count <= 0) {
return;
}
/**
* 注意,这里一定要删除页面上的View,而不是从缓存中拿出来删,那样是无用功
*/
for (int i = 1; i < count; i++) {
View child = getChildAt(i);
if (child == null) continue;
int itemViewType = getItemViewType(child);
if (!isStickyItemType(itemViewType)) {
continue;
}
int decoratedTop = getDecoratedTop(child);
if (decoratedTop <= 0) {
//删除 top <= 0的吸顶View,因为正常情况下页面child要么在吸顶,要么不可见了
removeAndRecycleView(child, recycler);
}
}
}
先让LayoutManager自己布局
我们要保证原始的布局逻辑保持不变,但是这时候吸顶的View可能也被加入了布局。了解过自定义View机制你就会知道,在布局方法或者onSizeChanged方法中频繁删除和重建View并不会影响展示,因此,我们可以把原有的View拿到,如果拿不到就从缓存中拿,拿到之后让其吸顶,且不会影响原有布局中的item位置。
我们开头说过,RecyclerView属于滑板派,只要你不requestLayout,每个View的left、top、right、bottom还是会保持原来的位置。
addView魔法
我们要知道的是,让其他ItemView不要盖住StickyView
我们文章开头说过: 后加入的View最后绘制,事件最优先接收,显然吸顶的View要在最后加入,才能不被遮盖。
问题是,吸顶的View可能已经加入进去了,怎么办?
我们文章开头还说过:
"减少addView、removeView、offsetXXX方法的调用,适当使用removeViewInLayout或者addViewInLayout,当然addViewInLayout外部无法调用,那就使用detachViewFromParent和attachViewFromParent",这些方法可以帮助我们调整View顺序,当然这是最初的想法。但是现实是RecyclerView 似乎和这些有冲突,然后去看addView源码,无意间发现LayoutManager#addView竟然可以移动View的顺序。
显然我们要做的是重置顺序,当然有人会说View#bingToFront不行么?如果在ScrollView中是可行的,但是在RecyclerView中是不行的,因为其内部有调用requestLayout,不适合滑动过程布局。
我们先看看addView核心逻辑,从代码中可以看到,其内部调用的方法很少触发requestLayout的条件,所以一定要知道的是,在滑动过程中切忌不要调用触发requestlayout的方法。
java
private void addViewInt(View child, int index, boolean disappearing) {
final ViewHolder holder = getChildViewHolderInt(child);
if (disappearing || holder.isRemoved()) {
mRecyclerView.mViewInfoStore.addToDisappearedInLayout(holder);
} else {
mRecyclerView.mViewInfoStore.removeFromDisappearedInLayout(holder);
}
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (holder.wasReturnedFromScrap() || holder.isScrap()) {
if (holder.isScrap()) {
holder.unScrap();
} else {
holder.clearReturnedFromScrapFlag();
}
mChildHelper.attachViewToParent(child, index, child.getLayoutParams(), false);
if (DISPATCH_TEMP_DETACH) {
ViewCompat.dispatchFinishTemporaryDetach(child);
}
} else if (child.getParent() == mRecyclerView) { // it was not a scrap but a valid child
// ensure in correct position
int currentIndex = mChildHelper.indexOfChild(child);
if (index == -1) {
index = mChildHelper.getChildCount();
}
if (currentIndex == -1) {
throw new IllegalStateException("Added View has RecyclerView as parent but"
+ " view is not a real child. Unfiltered index:"
+ mRecyclerView.indexOfChild(child) + mRecyclerView.exceptionLabel());
}
if (currentIndex != index) {
mRecyclerView.mLayout.moveView(currentIndex, index);
}
} else {
mChildHelper.addView(child, index, false);
lp.mInsetsDirty = true;
if (mSmoothScroller != null && mSmoothScroller.isRunning()) {
mSmoothScroller.onChildAttachedToWindow(child);
}
}
if (lp.mPendingInvalidate) {
if (DEBUG) {
Log.d(TAG, "consuming pending invalidate on child " + lp.mViewHolder);
}
holder.itemView.invalidate();
lp.mPendingInvalidate = false;
}
}
重新布局
首先我们知道页面上第一个View的位置,我们可以由此定位到其所在的分组itemViewType类型,如果其不属于要吸顶的item,那么继续向前搜索,如果是立即布局,下面首先查询可以吸顶且越第一个ItemView"血缘"最近的分组。
java
private View lookupStickyItemView(RecyclerView.Recycler recycler) {
int childCount = getChildCount();
if (childCount <= 0) {
return null;
}
//先看看第一个View是不是可以吸顶,如果不可以,则从缓存中查询
View view = getChildAt(0);
int itemViewType = getItemViewType(view);
int adapterPosition = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition();
View groupView = null;
if (!isStickyItemType(itemViewType)) {
//一般来说下,吸顶View的itemType在前面查询,如果要改成吸底的则在后面查询,因此这里逆序
for (int i = adapterPosition - 1; i >= 0; i--) {
//从缓存中查询
View childView = recycler.getViewForPosition(i);
//获取View类型
itemViewType = getItemViewType(childView);
if (isStickyItemType(itemViewType)) {
groupView = childView;
break;
}
}
} else {
//页面上第一个View就是吸顶的View
groupView = view;
}
if (groupView == null) {
Log.d(TAG, "not found " + itemViewType + " ,topChildPosition =" + adapterPosition);
return null;
}
return groupView;
}
布局
scss
addView(currentStickyItemView);
//测量多次没有问题,允许多次测量
measureChildWithMargins(currentStickyItemView, 0, 0);
int top = 0;
int right = getDecoratedMeasuredWidth(currentStickyItemView);
layoutDecoratedWithMargins(currentStickyItemView, 0, 0, right, bottom);
问题是,页面上可能有多个吸顶ItemView,当向上滑动时吸顶的View要保证下面要吸顶的不被遮盖,那就意味着吸顶的View需要滑动。
怎么做?
当然是查找当前吸顶View的下一个可吸顶的兄弟,当然我们只需要在页面上查找,Adapter查找没有意义,因为只会用到离当前吸顶View最近的,不在页面或者没出生的肯定不能算。
java
/**
* 获取当前页面布局区域内的所有吸顶View
* @return
*/
private List<View> getStickyItemViews() {
stickyAttachedViewList.clear();
int childCount = getChildCount();
if (childCount <= 0) {
return stickyAttachedViewList;
}
for (int i = 1; i < childCount; i++) {
View child = getChildAt(i);
if (child == null) continue;
int itemViewType = getItemViewType(child);
if (isStickyItemType(itemViewType)) {
stickyAttachedViewList.add(child);
}
}
return stickyAttachedViewList;
}
上面的查找肯定也会查找到正在吸顶的ItemView,为了避免逻辑错误,我们把其删除掉
java
/**
* 因为不能保证吸顶的View顺序是最理想的按默认排列,因此这里正在西定的View在绘制顺序的最顶部,
* 但是其他可以吸顶的View是正常顺序,因此删除掉,从开始位置计算,如果下一个离正在吸顶View最近的View顶到了它 (哈哈,莫要想歪了),
* 那么就得让他偏移
*/
stickyChildren.remove(currentStickyItemView);
那么位置计算呢? 首先吸顶的View top 默认是0,因此向上滑动top应该变成负值,我们用下一个要吸顶的View的top减去当前吸顶View的高度即可,但是前提是这个高度必须已经触及了正在吸顶View的边缘。
java
for (int index = 0; index < size; index++) {
View nextChild = stickyChildren.get(index);
int nextStickyViewTop = getDecoratedTop(nextChild);
if (nextStickyViewTop < topStickyViewTop) {
continue;
}
if (nextStickyViewTop > topStickyViewHeight) {
continue;
}
top = nextStickyViewTop - topStickyViewHeight; //计算偏移距离
break;
}
调整布局逻辑
scss
int bottom = top + topStickyViewHeight;
layoutDecoratedWithMargins(currentStickyItemView, 0, top, getDecoratedMeasuredWidth(currentStickyItemView), bottom);
用法
为了方便使用,我们其实使用GridLayoutManager实现了吸顶灯效果,下面是本文效果图的展示实现。
ini
public class MainActivity extends Activity {
private RecyclerView recyclerView;
private QuickAdapter quickAdapter;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.recycle_main);
recyclerView = findViewById(R.id.recycleView);
int[] stickyItemTypes = new int[]{
ItemType.VIEW_TYPE_GROUP, //此类型需要吸顶
ItemType.VIEW_TYPE_GROUP_ICON //此类型需要吸顶
};
recyclerView.setLayoutManager(new StickyGridLayoutManager(this, stickyItemTypes,1));
quickAdapter = new QuickAdapter(createFakeDatas());
recyclerView.setAdapter(quickAdapter);
}
private List<DataModel> createFakeDatas() {
List<DataModel> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
DataModel child = new ItemDataModel("第" + 0 + "组第" + (i + 1) + "号");
list.add(child);
}
for (int g = 0; g < 10; g++) {
DataModel group = (g % 2 == 0) ? new GroupDataModel("第" + (g + 1) + "组") : new GroupDataModelIcon("第" + (g + 1) + "组");
list.add(group);
int count = (int) (10 + 10 * Math.random());
for (int i = 0; i < count; i++) {
DataModel child = new ItemDataModel("第" + (g + 1) + "组第" + (i + 1) + "号");
list.add(child);
}
}
return list;
}
}
总结
特点
到这里我们创建吸顶LayoutManager就结束了,相比网上的其他两种方案,这种方案优势明显:
- 耦合度更小
- 可移植性更高
- 状态不需要同步
- 支持事件
- 不依赖itemDecoration
- 不依赖父布局
- 不依赖Adapter
全部代码
按照惯例,这里提供实现源码,方便大家参考和改造。
java
public class StickyGridLayoutManager extends GridLayoutManager {
private static final String TAG = "StickyGridManager";
private final List<View> stickyAttachedViewList = new ArrayList<>();
private int[] stickyItemTypes = null;
public StickyGridLayoutManager(Context context, int[] stickyItemTypes, int spanCount) {
super(context, spanCount);
this.stickyItemTypes = stickyItemTypes;
}
public StickyGridLayoutManager(Context context, int[] stickyItemTypes, int spanCount, int orientation, boolean reverseLayout) {
super(context, spanCount, orientation, reverseLayout);
this.stickyItemTypes = stickyItemTypes;
}
public StickyGridLayoutManager(Context context, AttributeSet attrs, int[] stickyItemTypes, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
this.stickyItemTypes = stickyItemTypes;
}
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (this.stickyItemTypes == null
|| this.stickyItemTypes.length == 0
|| getOrientation() != RecyclerView.VERTICAL) {
super.onLayoutChildren(recycler, state);
return;
}
//先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
super.onLayoutChildren(recycler, state);
//布局吸顶的View
layoutStickyView(recycler, state);
}
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (this.stickyItemTypes == null || this.stickyItemTypes.length == 0) {
return super.scrollVerticallyBy(dy, recycler, state);
}
//先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
int scrollOffsetY = super.scrollVerticallyBy(dy, recycler, state);
//布局吸顶的View
layoutStickyView(recycler, state);
return scrollOffsetY;
}
private void layoutStickyView(RecyclerView.Recycler recycler, RecyclerView.State state) {
View currentStickyItemView = lookupStickyItemView(recycler);
if (currentStickyItemView == null) {
return;
}
/**
* 下面方法将当前要吸顶的View添加进去
* 注意1:addView被RecyclerView魔改过,正常情况下一个View只能被addView一次
* 注意2: LayoutManager的addView会尽可能抑制requestLayout,正常情况下,addView必然会requestLayout
* 注意3: LayoutManager多次addView同一个View,如果两次位置不一样,那只会改变View的加入顺序和绘制顺序
* 注意4: 在Android系统的中,最后加入的View绘制顺序和接受事件的优先级是最高的。
*/
addView(currentStickyItemView);
measureChildWithMargins(currentStickyItemView, 0, 0);
List<View> stickyChildren = getStickyItemViews();
int top = 0;
int topStickyViewHeight = getDecoratedMeasuredHeight(currentStickyItemView);
int topStickyViewTop = getDecoratedTop(currentStickyItemView);
/**
* 因为不能保证吸顶的View顺序是最理想的按默认排列,因此这里正在西定的View在绘制顺序的最顶部,
* 但是其他可以吸顶的View是正常顺序,因此删除掉,从开始位置计算,如果下一个离正在吸顶View最近的View顶到了它 (哈哈,莫要想歪了),
* 那么就得让他偏移
*/
stickyChildren.remove(currentStickyItemView);
int size = stickyChildren.size();
for (int index = 0; index < size; index++) {
View nextChild = stickyChildren.get(index);
int nextStickyViewTop = getDecoratedTop(nextChild);
if (nextStickyViewTop < topStickyViewTop) {
continue;
}
if (nextStickyViewTop > topStickyViewHeight) {
continue;
}
top = nextStickyViewTop - topStickyViewHeight; //计算偏移距离
break;
}
int bottom = top + topStickyViewHeight;
layoutDecoratedWithMargins(currentStickyItemView, 0, top, getDecoratedMeasuredWidth(currentStickyItemView), bottom);
}
/**
* 获取当前页面布局区域内的所有吸顶View
* @return
*/
private List<View> getStickyItemViews() {
stickyAttachedViewList.clear();
int childCount = getChildCount();
if (childCount <= 0) {
return stickyAttachedViewList;
}
for (int i = 1; i < childCount; i++) {
View child = getChildAt(i);
if (child == null) continue;
int itemViewType = getItemViewType(child);
if (isStickyItemType(itemViewType)) {
stickyAttachedViewList.add(child);
}
}
return stickyAttachedViewList;
}
@Nullable
private View lookupStickyItemView(RecyclerView.Recycler recycler) {
int childCount = getChildCount();
if (childCount <= 0) {
return null;
}
//先看看第一个View是不是可以吸顶,如果不可以,则从缓存中查询
View view = getChildAt(0);
int itemViewType = getItemViewType(view);
int adapterPosition = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition();
View groupView = null;
if (!isStickyItemType(itemViewType)) {
//一般来说下,吸顶View的itemType在前面查询,如果要改成吸底的则在后面查询,因此这里逆序
for (int i = adapterPosition - 1; i >= 0; i--) {
//从缓存中查询
View childView = recycler.getViewForPosition(i);
//获取View类型
itemViewType = getItemViewType(childView);
if (isStickyItemType(itemViewType)) {
groupView = childView;
break;
}
}
} else {
//页面上第一个View就是吸顶的View
groupView = view;
}
if (groupView == null) {
Log.d(TAG, "not found " + itemViewType + " ,topChildPosition =" + adapterPosition);
return null;
}
return groupView;
}
private boolean isStickyItemType(int itemViewType) {
if (this.stickyItemTypes == null || this.stickyItemTypes.length == 0) {
return false;
}
for (int i = 0; i < this.stickyItemTypes.length; i++) {
if(this.stickyItemTypes[i] == itemViewType){
return true;
}
}
return false;
}
/**
* 删除正在吸顶的View
* @param recycler
*/
private void removeStickyView(RecyclerView.Recycler recycler) {
int count = getChildCount();
if (count <= 0) {
return;
}
/**
* 注意,这里一定要删除页面上的View,而不是从缓存中拿出来删,那样是无用功
*/
for (int i = 1; i < count; i++) {
View child = getChildAt(i);
if (child == null) continue;
int itemViewType = getItemViewType(child);
if (!isStickyItemType(itemViewType)) {
continue;
}
int decoratedTop = getDecoratedTop(child);
if (decoratedTop <= 0) {
//删除 top <= 0的吸顶View,因为正常情况下页面child要么在吸顶,要么不可见了
removeAndRecycleView(child, recycler);
}
}
}
}