前语

在之前的一些文章中,咱们完结过各种各样的布局作用,当然也有吸顶作用,在写本篇之前能够看看之前的文章。

上面的文章根本都是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_CENTERKEY_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。

这招能够称为“釜底抽薪”

这儿咱们只需求在布局之前将锚点移除

//先移除吸顶的View,避免LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候或许会将吸顶View参加进去,不过不要紧,RecyclerView的addView很强壮
super.onLayoutChildren(recycler, state);

相同纵向也是

//先移除吸顶的View,避免LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候或许会将吸顶View参加进去,不过不要紧,RecyclerView的addView很强壮
int scrollOffsetY = super.scrollVerticallyBy(dy, recycler, state);

删去可见View

删去怎样删呢,怎样知道哪些要被删去呢,其实咱们这儿需求界说ItemViewType,和Adapter中的itemViewType映射。

private int[] stickyItemTypes = null;

删去的时候,不是从缓存中拿View,而是删去上一次在界面上存在的View,当然,咱们要删的是吸顶的View和移出视觉区域的View,而不是一切的见面上的Sticky View。

/**
 * 删去正在吸顶的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的办法。

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“血缘”最近的分组。

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;
}

布局

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最近的,不在页面或许没出生的必定不能算。

/**
 * 获取当时页面布局区域内的一切吸顶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,为了避免逻辑过错,咱们把其删去掉

/**
 * 由于不能确保吸顶的View次序是最理想的按默许摆放,因而这儿正在西定的View在制作次序的最顶部,
 * 可是其他能够吸顶的View是正常次序,因而删去掉,从开始方位核算,假如下一个离正在吸顶View最近的View顶到了它 (哈哈,莫要想歪了),
 * 那么就得让他偏移
 */
stickyChildren.remove(currentStickyItemView);

那么方位核算呢?
首要吸顶的View top 默许是0,因而向上滑动top应该变成负值,咱们用下一个要吸顶的View的top减去当时吸顶View的高度即可,可是条件是这个高度必须现已触及了正在吸顶View的边缘。

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);

用法

为了便利运用,咱们其实运用GridLayoutManager完结了吸顶灯作用,下面是本文作用图的展现完结。

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

悉数代码

按照惯例,这儿供给完结源码,便利咱们参考和改造。

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);
            }
        }
    }
}