从零开始实现SnakeLayoutManager(原创)
界面原型图

没错,就长这个亚子,扭来扭去的。
实际效果图

我们该怎么去思考呢???
其实仔细一点的同学就会发现,这玩意和网格看起来略有相似之处嘛~ 那不妨用RecyclerView的GridLayoutManager去布局咯,事实上这样的方案是可行的,不过需要处理position,在第2行开始(行数从0开始),将position倒一下序才行,而且还需要让第1列(列数就从0开始)和第3列单独占一行,还得判断是在左边还是右边,这样处理起来还是比较麻烦的,因为你需要在适配器、布局管理器等地方进行设置才行,无疑这是一个不太优雅的解决方案。
我们该如何优雅的实现这个布局呢???
我们知道RecyclerView是通过LayoutManager来实现各种各样的布局效果的,也就是说LayoutManger是控制如何摆放ItemView的,所以我们可以自定义LayoutManger,这样我们就可以像 LinearLayoutManager 一样使用这个效果了!那我们就自定义一个SnakeLayoutManager咯~(因为这个布局和一条蛇一样扭来扭去的??)
找布局的规律
在自定义布局管理器之前,我们先来找找这个Snake布局的规律吧,我们先从简单的开始,其他列数的规律其实是类似的,我用Excel画了个表,用来直观的观察这个规律。
可能大家没看出来有啥规律,但是脑瓜子灵光的小伙伴会在这里建立一个坐标系(我就是,哈哈哈哈  皮一下很开心??)
 
 
那么position和坐标之间的对应关系如下表格所示
我们可以看出,x轴的坐标是每隔三个相同,然后加1,完了之后再加1,以此类推,y轴的坐标则是按照 0、1、2、2、2、1、0、0  这样的规律重复出现的,既然每行3列的是这样,那效果图中的每行6列是不是也是类似的规律呢??   答案是肯定的,有兴趣的小伙伴可以自己去试一下,下面给出了每行3列,每行4列,每行5列的效果图进行对照,至于6列的就没画了(还不是因为懒??)。
  
 
布局管理器代码部分
既然规律都知道了,那就上代码吧,注释都很详细了,就没必要再解释了,相信大家都能看懂~
public class SnakeLayoutManger extends RecyclerView.LayoutManager {
    private static final String TAG = "SnakeLayoutManger";
    //默认一行最多几个
    public static final int DEFAULT_SPAN_COUNT = 6;
    private int mSpanCount = DEFAULT_SPAN_COUNT;
    public static final int DEFAULT_TURNING_POINT_POSITION = 5;
    //转折点的位置
    private int mTurningPointPosition = DEFAULT_TURNING_POINT_POSITION;
    public SnakeLayoutManger() {
    }
    public SnakeLayoutManger(int spanCount, int turningPoint, int... rule) {
        mSpanCount = spanCount;
        mTurningPointPosition = turningPoint;
        mXIndexList.clear();
        for (int xPosition : rule) {
            mXIndexList.add(xPosition);
        }
    }
    public SnakeLayoutManger(int spanCount, int turningPoint, @NonNull List<Integer> rule) {
        mSpanCount = spanCount;
        mTurningPointPosition = turningPoint;
        mXIndexList.clear();
        mXIndexList.addAll(rule);
    }
    
    /**
     * 获取一行最多有多少列
     * 
     * @return
     */
    public int getSpanCount() {
        return mSpanCount;
    }
    
    /**
     * 获取拐点的位置
     * 
     * @return
     */
    public int getTurningPointPosition() {
        return mTurningPointPosition;
    }
    /**
     * 生成默认布局参数(这个方法必须重写)
     * 
     * @return
     */
    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
    }
    //是否已经布局过了
    private boolean isLayoutChildren = false;
    //存放摆放ItemView规律的集合
    private List<Integer> mXIndexList = new ArrayList<>();
    /**
     *
     * ==========第一次完整的规律 start=========
     * (0,0)    (0,1)    (0,2)
     *                   (1,2)
     * (2,0)    (2,1)    (2,2)
     * (3,0)
     * ==========第一次完整的规律 end===========
     *
     * ==========第二次完整的规律 start=========
     * (4,0)    (4,1)    (4,2)
     *                   (5,2)
     * (6,0)    (6,1)    (6,2)
     * (7,0)
     * ==========第二次完整的规律 end===========
     *
     */
    {
        //x轴坐标排列的规律(需要一次完整的规律,后续的摆放规律都从这里获取)
        mXIndexList.add(0);
        mXIndexList.add(1);
        mXIndexList.add(2);
        mXIndexList.add(3);
        mXIndexList.add(4);
        mXIndexList.add(5);
        mXIndexList.add(5);
        mXIndexList.add(5);
        mXIndexList.add(4);
        mXIndexList.add(3);
        mXIndexList.add(2);
        mXIndexList.add(1);
        mXIndexList.add(0);
        mXIndexList.add(0);
    }
    //1、在RecyclerView初始化时,会被调用两次。
    //2、在调用adapter.notifyDataSetChanged()时,会被调用。
    //3、在调用setAdapter替换Adapter时,会被调用。
    //4、在RecyclerView执行动画时,它也会被调用。
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        int itemCount = getItemCount();
        //如果Item的个数为0,则认为没必要对ItemView进行布局
        //state.isPreLayout() 是否支持动画
        if (itemCount == 0 || state.isPreLayout()) return;
        if (mXIndexList == null || mXIndexList.size() == 0) {
            throw new RuntimeException("======== mXIndexList is null or mXIndexList is empty!");
        }
        if (isLayoutChildren) return;
        //分离并报废附加视图(解除ItemView的绑定)
        detachAndScrapAttachedViews(recycler);
        //容器的宽度
        int containerWidth = getWidth();
        //ItemView在y轴上的坐标
        int yPosition = 0;
        //用来判断是否y轴坐标需要增加
        int count = 0;
        for (int i = 0; i < itemCount; i++) {
            View targetView = measure(recycler, i);
            //获得ItemView测量后的宽度
            int width = getDecoratedMeasuredWidth(targetView);
            //获得ItemView测量后的高度
            int height = getDecoratedMeasuredHeight(targetView);
            int itemViewSize = Math.min(width, height);
            //ItemView的margin值 = (总的宽度 - padding值 - 一行Item所占的总宽度)/ 一行Item的个数
            int margin = (containerWidth - getPaddingLeft() - getPaddingRight() - itemViewSize * getSpanCount()) / getSpanCount();
            int xIndex = i % mXIndexList.size();
            //获取ItemView在x轴上的坐标
            Integer xPosition = mXIndexList.get(xIndex);
            Log.d(TAG, "currentPosition============>xPosition:" + xPosition + " yPosition:" + yPosition + " index:" + i);
            //左边界的位置
            int left = xPosition * (width + margin);
            //有边界的位置
            int right = (xPosition + 1) * (width + margin);
            //上边界的位置
            int top = yPosition * (height + margin);
            //下边界的位置
            int bottom = (yPosition + 1) * (height + margin);
            //根据边界值摆放ItemView
            layoutDecorated(targetView, left, top, right, bottom);
            if (betweenAnd(0, getTurningPointPosition(), count)) {
                //如果在转折点范围之内,并且等于转折点的x轴坐标,则y轴坐标+1
                if (count == getTurningPointPosition()) {
                    yPosition += 1;
                }
                count++;
            } else {
                //如果大于转折点的x轴坐标,将当前次数重新初始化,并将y轴坐标+1
                count = 0;
                yPosition += 1;
            }
        }
        isLayoutChildren = true;
    }
    /**
     * 判断是否在最大值和最小值之间 [min,max]
     *
     * @param minValue
     * @param maxValue
     * @param targetValue
     * @return
     */
    private boolean betweenAnd(int minValue, int maxValue, int targetValue) {
        return targetValue <= maxValue && targetValue >= minValue;
    }
    /**
     * 测量并添加item
     *
     * @param recycler
     * @param index
     * @return
     */
    private View measure(RecyclerView.Recycler recycler, int index) {
        //获取该位置的ItemView
        View targetView = recycler.getViewForPosition(index);
        //将ItemView添加到容器中
        addView(targetView);
        //测量带边距的孩子
        measureChildWithMargins(targetView, 0, 0);
        return targetView;
    }
    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        //界面向下滚动的时候,dy为正,向上滚动的时候dy为负
        //填充
        //fill(dy, recycler, state);
        //滚动
        offsetChildrenVertical(-dy);
        //回收已经离开界面的
        //recycleOut(dy, recycler, state);
        return dy;
    }
    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        //填充
        //fill(dy, recycler, state);
        offsetChildrenHorizontal(-dx);
        //回收已经离开界面的
        //recycleOut(dy, recycler, state);
        return dx;
    }
    private void fill(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        //向下滚动
        if (dy > 0) {
            // TODO: 2020/7/5 向底部填充
        } else {
            //向上滚动
            // TODO: 2020/7/5 向顶部填充
        }
    }
    /**
     * 回收ItemView
     *
     * @param dy
     * @param recycler
     * @param state
     */
    private void recycleOut(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        for (int i = 0; i < getChildCount(); i++) {
            View view = getChildAt(i);
            if (view == null) return;
            if (dy > 0) {
                if (view.getBottom() - dy < 0) {
                    LogUtils.d("recycleOut==============>  " + i);
                    removeAndRecycleView(view, recycler);
                }
            } else {
                if (view.getTop() - dy > getHeight()) {
                    LogUtils.d("recycleOut==============>  " + i);
                    removeAndRecycleView(view, recycler);
                }
            }
        }
    }
}
写在最后
当然,这个SnakeLayoutManager还有很多可以优化的地方,比如处理滚动事件和复用问题。本文只是介绍如何进行布局,感兴趣的同学可以自己实现一下。
如果觉得本文不错,点个赞再走呗~
允许转载至《阳光沙滩》微信公众号,其他转载请联系:2695734816@qq.com



 拉大锯  回复 @A lonely cat
 拉大锯  回复 @A lonely cat 



























