主页 > 开发文档 > 一个有特点的正六边形RecyclerView---HexagonRecyclerV

一个有特点的正六边形RecyclerView---HexagonRecyclerV

背景

1.一直有那么一个冲动,想写一个自己的控件,然后开源在Github,充满着莫名的成就感。

2.正好朋友的需求给了我灵感,然后对这个控件产生了自定义的冲动。

3.本身最近在学习RecyclerView和自定义View,正好可以巩固一下知识点,并且还有成就感。

4.这个控件,看起来挺小众的,但是你看图片会感觉这个控件挺有创意的,学会了HexagonRecyclerView,那么类似的奇怪的需求几乎都没有问题了。

5.这也是这个月的最后一篇博客,这个月给自己的小目标也算完成了。


前言

(1)HexagonRecyclerView介绍篇,它是由什么组成,然后基本的展示.

(2)自定义LayoutManager的基本思路.

(3)HexagonRecyclerView的自定义LayoutManager的过程,包括正六边形计算相关的拆分.

(4)正六边形的自定义View详解.

(5)HexagonRecyclerView后续的一些扩展方向.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

HexagonRecyclerView介绍篇

HexagonRecyclerView的基本介绍

HexagonRecyclerView是一个由2列正六边形组成的RecyclerView,可以做侧边索引,可以作为导航栏来使用,是通过自定义LayoutManager,来实现这样的效果。

Github开源地址,欢迎Star

这里写图片描述

具体的介绍这里可以参考Github的ReadMe或者我的这篇博客:

HexagonRecyclerView的使用介绍


HexagonRecyclerView的基本组成

虽然名字中带有RecyclerView,但是HexagonRecyclerView的库中却没有重新定义RecyclerView,而是重新定义LayoutManager,因为LayoutManager是负责RecyclerView的绘制、布局、测量的。

HexagonRecyclerView

HexagonRecyclerView库中包括:

(1)PolygonItemView :为自定义的正六边形View

(2)PolygonLayoutManager : 为RecyclerView展示需要的LayoutManager

(3)Pool : 自定义的View的存储池,负责View的复用。

(4)MathUtil : 存储公式的静态类,负责三角函数公式的调用。


HexagonRecyclerView的简单使用

Adapter的ItemView

    <com.vander.hexlayout.PolygonItemView
        android:id="@+id/itemview"
        android:layout_width="110dp"
        android:layout_height="110dp"
        app:innerColor="@android:color/white"
        app:isFull="true"
        app:outerColor="#f5c421"
        app:outerWidth="1dp"
        app:radius="50dp" />
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

RecyclerView的LayoutManager

        PolygonLayoutManager manager = new PolygonLayoutManager(true);
        manager.setLandscapeInterval(0);
        mMainRv.setLayoutManager(manager);
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

详情,请见HexagonRecyclerView的具体使用Simple


自定义LayoutManager的思路

其实,当你了解LayoutManager之后,你只需要配合一些数学基础,就能写出这样的控件。如果你有心,看了这个库,你会发现其实没多少代码,当然,这个库能写出来最大的核心点就是自定义LayoutManager。

那么下面就开始介绍下自定义LayoutManager的思路:

(1)先设置RecyclerView的子View的LayoutParams参数。

(2)通过Rect将每个子View的测量位置记录,然后缓存起来。

(3)判断当前Rect是否在屏幕上,如果存在就对其布局操作即执行layoutDecorate()方法。

(4)设置RecyclerView的滑动方向,分别通过canScrollHorizontally(),canScrollVertically()设置横向或者竖向能否滑动。

(5)设置RecyclerView的滑动,先设置其上下边界,然后赋予偏移量,重新布局即形成滑动效果。


设置子View的LayoutParams参数

首先,要继承RecyclerView.LayoutManager就必须实现generateDefaultLayoutParams()这个方法,然后设置其子View的LayoutParmas

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        //比较默认的设置,可以根据自己的需求来定制。
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

复写onLayoutChildren()方法

然后,我们复写其onLayoutChildren()方法,因为在自定义View的过程中,测量,布局,绘制是必不可少的步骤,而在完成基本自定义LayoutManager,我们只需要重点关注其布局和滑动步骤,而onLayoutChildren()作为LayoutManager的布局方法,在自定义LayoutManager中,属于最核心的部分。

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        //准备阶段
        initLayout();
        //测算阶段
        measureLayout();
        //布局阶段
        fill();
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

在这里我们可以,将onLayoutChildren()大致分为三个阶段,分别为准备阶段、测算阶段、布局阶段。

(1)第一阶段 - 准备阶段

    /**
     * onLayoutChildren 准备阶段
     * @param recycler
     * @param state
     */
    private void initLayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getItemCount() <= 0 || state.isPreLayout()) {
            return;
        }
        //将所有子View先detach一遍,然后标记“Scrap”缓存起来
        detachAndScrapAttachedViews(recycler);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

(2)第二阶段 - 测算阶段

    private void measureLayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
        //测量每个子View的基本信息
        View normalView = recycler.getViewForPosition(0);
        measureChildWithMargins(normalView, 0, 0);
        int itemWidth = getDecoratedMeasuredWidth(normalView);
        int itemHeight = getDecoratedMeasuredHeight(normalView);

        for (int i = 0; i < getItemCount(); i++) {
            //mItemFrames为Rect的池对象,就是Rect的一个容器,索引为i。
            Rect item = mItemFrames.get(i);
            .....
            //设置每个子View的Rect的范围
            .....
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

在这里需要自定义一个Rect的池,来储存生成Rect的范围,最后用于判断该View是否处于屏幕上,负责会被回收掉。

(3)第三阶段 - 布局阶段

    /**
     * 布局阶段
     *
     * @param recycler
     * @param state
     */
    private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getItemCount() <= 0 || state.isPreLayout()) {
            return;
        }
        //考虑到当前RecyclerView会处于滑动的状态,所以这里的Rect的作用是展示当前显示的区域
        //需要考虑到RecyclerView的滑动量
        //mHorizontalOffset 横向的滑动偏移量
        //mVerticalOffset  纵向的滑动偏移量
        Rect displayRect = new Rect(0, mVerticalOffset,
                getHorizontalSpace(),
                getVerticalSpace() + mVerticalOffset);

        /**
         * 对这些View进行测量和布局操作
         */
        for (int i = 0; i < getItemCount(); i++) {
            Rect frame = mItemFrames.get(i);
            if (Rect.intersects(displayRect, frame)) {
                View scrap = recycler.getViewForPosition(i);
                addView(scrap);
                //测量子View
                measureChildWithMargins(scrap, 0, 0);
                //布局方法
                layoutDecorated(scrap, frame.left - mHorizontalOffset, frame.top - mVerticalOffset,
                        frame.right - mHorizontalOffset, frame.bottom - mVerticalOffset);
            }
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

这里的displayRect是当前显示的区域,然后我们通过Rect.intersects()方法,判断子View是否与displayRect相交,如果相交即子View显示在RecyclerView的展示区域上,然后会对该子View进行布局操作。

这里需要注意的是,在计算每个子View的位置时,需要考虑RecyclerView滑动的偏移量。


处理RecyclerView的滑动

其实,经过上面的两个步骤,RecyclerView显示已经没有什么大的问题了,如果是自定义LayoutManager不需要考虑滑动,其实这样已经能看到效果了,那么接下来我们应该处理RecyclerView的滑动,来使控件能够得到更好的体验。

处理RecyclerView滑动也分两个阶段:

(1)设置控件在横向、竖向上能否滑动。

(2)记录各个方向上的偏移量,使RecyclerView的各个子View偏移,然后重新进行布局操作。
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

(1)第一阶段 - 设置控件能否滑动

设置RecyclerView在横向能否滑动:

    @Override
    public boolean canScrollHorizontally() {
        return true;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

设置RecyclerView在竖向能否滑动:

    @Override
    public boolean canScrollVertically() {
        return true;
    }
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

上述两个方法,根据返回值来决定当前RecyclerView在各个方向上是否可以滑动。

(2)记录偏移量,并且设置子View偏移,重新进行布局操作

设置纵向偏移

   @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        detachAndScrapAttachedViews(recycler);
        //上边界判断
        if (mVerticalOffset + dy < 0) {
            dy = -mVerticalOffset;
            //下边界判断
        } else if (mVerticalOffset + dy > mTotalHeight - getVerticalSpace()) {
            dy = mTotalHeight - getVerticalSpace() - mVerticalOffset;
        }

        mVerticalOffset += dy;
        //使所有ChildView偏移 如果向上滑动 所有View就向下偏移 反之亦然
        offsetChildrenVertical(-dy);
        //重新布局
        fill(recycler, state);
        return dy;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

设置横向偏移

public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
    detachAndScrapAttachedViews(recycler);
    //左边界判断
    if (mHorizontalOffset + dx < 0) {
        dx = -mHorizontalOffset;
        //右边界的判断
    } else if (mHorizontalOffset + dx > mTotalWidth - getHorizontalSpace()) {
        dx = mTotalWidth - getHorizontalSpace() - mHorizontalOffset;
    }
    mHorizontalOffset += dx;
    //使所有ChildView偏移 如果向左滑动 所有View就向右偏移 反之亦然
    offsetChildrenHorizontal(-dx);
    //重新进行布局操作
    fill(recycler, state);
    return dx;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

通过上面两个方法,可以很容易的发现,其实处理横向或者纵向滑动很简单,上述两个方法,几乎就是模板方法。

不管是横向、或者竖向滑动,这两个方法处理的流程几乎都是一致的,如下:

(1)首先进行边界判断,滑动到边界,就不能继续在滑动了。

(2)然后记录横向或纵向的偏移量,进行offsetChildrenHorizontal()或者offsetChildrenVertical()操作,使子View进行偏移。

(3)最后调用fill(),进行布局操作,完成RecyclerView的滑动处理。

在这里getHorizontalSpace()、和getVerticalSpace()都属于辅助方法 :

//测量RecyclerView的整体横向距离,注意这段距离不包括padding操作,需要减掉
 private int getHorizontalSpace() {
        return getWidth() - getPaddingLeft() - getPaddingRight();
    }

//测量RecyclerView的整体纵向距离,注意这段距离不包括padding操作,需要减掉
private int getVerticalSpace() {
        return getHeight() - getPaddingTop() - getPaddingBottom();
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

而mTotalWidth 和mTotalHeight 则是在onLayoutChildren()提前计算好的,分别代表LayoutManager中自定义的内容宽度,和高度。

到这里,其实自定义LayoutManager的基本流程,已经基本完成。

最后来一张图,总结一下:

友情提示:由于图片幅度过大,需要放大到150%,才能清晰观看。

自定义LayoutManager的流程


HexagonRecyclerView的自定义流程

下面的内容,将会着重介绍HexagonRecyclerView这个组件的自定义流程,如果对此感兴趣,不妨来一发Star。

容许我在来一次Github链接

HexagonRecyclerView的自定义流程分为两个阶段:

(1)自定义 LayoutManager,以此来控制正六边形的显示。

(2)自定义正六边形 View,以此来高度定制 RecyclerView 中的展示风格。


PolygonLayoutManager的自定义流程

(1)先设置RecyclerView的子View的LayoutParams参数。

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

在这里对子 View 的大小没有特殊要求,所以宽高自适应就可以。


(2)onLayoutChildren()的三个流程的实现

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        //准备阶段
        initLayout();
        //测算阶段
        measureLayout();
        //布局阶段
        fill();
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

第一阶段 : 准备阶段

    /**
     * onLayoutChildren 准备阶段
     * @param recycler
     * @param state
     */
    private void initLayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getItemCount() <= 0 || state.isPreLayout()) {
            return;
        }
        //将所有子View先detach一遍,然后标记“Scrap”缓存起来
        detachAndScrapAttachedViews(recycler);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

一般来说,这个阶段,所需要做的操作,几乎都是固定的。所以不需要做太大改动。


第二阶段 :测算阶段

这一阶段比较重要,决定着正六边形的展示形式,可以调整边距,以及展示位置。

    /**
     * 测量布局 - 阶段
     *
     * @param recycler
     * @param state
     */
    private void measureLayout(RecyclerView.Recycler recycler, RecyclerView.State state) {
        View normalView = recycler.getViewForPosition(0);
        measureChildWithMargins(normalView, 0, 0);

        int itemWidth = getDecoratedMeasuredWidth(normalView);
        int itemHeight = getDecoratedMeasuredHeight(normalView);

        //正六边形外圆的半径
        int radius = itemWidth / 2;

        mVerticalInterval = (mLandscapeInterval / MathUtil.sin(60)) - 2 * (radius - radius * MathUtil.sin(60));

        //每组的最大宽度 第一排的宽度加上第二排的宽度
        //这里的0.75 * itemWidth 和 (3/2)R表达的意思都是一致的.
        int mGroupWidth = (int) (0.75 * itemWidth + itemWidth - mLandscapeInterval);

        if (isGravityCenter && mGroupWidth < getHorizontalSpace()) {
            mGravityOffset = (getHorizontalSpace() - mGroupWidth) / 2;
        } else {
            mGravityOffset = 0;
        }

        for (int i = 0; i < getItemCount(); i++) {
            Rect item = mItemFrames.get(i);
            int offsetHeight = (int) ((i / GROUP_SIZE) * (itemHeight + mVerticalInterval));
            //每组的第一行
            if (isItemInFirstLine(i)) {
                int left = mGravityOffset;
                int top = offsetHeight;
                int right = mGravityOffset + itemWidth;
                int bottom = itemHeight + offsetHeight;
                item.set(left, top, right, bottom);
//                Log.d("第一段的高度", "left : " + left);
//                Log.d("第一段的高度", "top : " + top);
//                Log.d("第一段的高度", "right : " + right);
//                Log.d("第一段的高度", "bottom : " + bottom);
            } else {
                //X轴的偏移是从 正六边形的外圆 3/2 R出开始偏移
                int itemOffsetWidth = (int) ((3f / 2f) * radius + mLandscapeInterval);
                //Y轴的第一次偏移是 取 (2个正六边形的宽度 + 中间间距) 得到当前第二排正六边形的中点 然后往回减 得到的.
                int itemOffsetHeight = (int) ((int) ((2 * itemWidth + mVerticalInterval) / 2) - 0.5 * itemWidth);
                int left = mGravityOffset + itemOffsetWidth;
                int top = itemOffsetHeight + offsetHeight;
                int right = mGravityOffset + itemOffsetWidth + itemWidth;
                int bottom = offsetHeight + itemOffsetHeight + itemHeight;
                item.set(left, top, right, bottom);
//                Log.d("第二段的高度", "left : " + left);
//                Log.d("第二段的高度", "top : " + top);
//                Log.d("第二段的高度", "right : " + right);
//                Log.d("第二段的高度", "bottom : " + bottom);
            }
        }
        //设置总的宽度
        mTotalWidth = Math.max(mGroupWidth, getHorizontalSpace());
        //设置总的高度
        int totalHeight = (int) (getGroupSize() * itemHeight + (getGroupSize() - 1) * mVerticalInterval);
        //判断当前最后一组如果不是第一行,那么高度还得加上第一段偏移量
        //Y轴的第一段偏移量
        int itemOffsetHeight = (int) ((int) ((2 * itemWidth + mVerticalInterval) / 2) - 0.5 * itemWidth);
        if (!isItemInFirstLine(getItemCount() - 1)) {
            totalHeight += itemOffsetHeight;
        }
        //设置总的高度 取当前的内容 和 RecyclerView的高度的最大值
        mTotalHeight = Math.max(totalHeight, getVerticalSpace());
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71

在这一阶段,主要做了这几件事:

(1)测量第一个子 View 的宽高,然后获得当前正六边形的外接圆半径。

正六边形

其实每个正六边形,都是这么画出来的,先做一个辅助的外接圆,然后寻找每一个相交的点就可以。

这里我们能够测量出该控件的宽为 AB,高为 AD,这时候外接圆的半径为 AB的一半。

(2)我们需要算出正六边形竖向之间的距离,请看下图:

示意图

在这里先说明两个常量,mLandscapeInterval 和 mVerticalInterval :

mLandscapeInterval:代表的是正六边形形成的正三角形的中线,如图就是那条蓝色的线。

注意:横向间距这里是通过自定义实现的,也就是你自己来设定的。

mVerticalInterval:代表的是竖向间距,即等边三角形的边长 减去 2倍的 外接圆与正六边形的边界差。

我们看自定义的正六边形图,可以发现View的边界不是在正六边形上,而是在外接圆上。所以需要将多余的地方减掉。

        mVerticalInterval = (mLandscapeInterval / MathUtil.sin(60)) - 2 * (radius - radius * MathUtil.sin(60));
  • 1
  • 1

对比下这两幅图,可以很容易的出这样的公式:

纵向距离 = AB - 2 * (R - R *sin60°)

(3) 计算每组正六边形的最大宽度,是否小于RecyclerView的宽度,如果小于,则计算出偏移量,使其居中。

        int mGroupWidth = (int) (0.75 * itemWidth + itemWidth - mLandscapeInterval);

        if (isGravityCenter && mGroupWidth < getHorizontalSpace()) {
            mGravityOffset = (getHorizontalSpace() - mGroupWidth) / 2;
        } else {
            mGravityOffset = 0;
        }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

这里需要说明的是,我们指的一组是两个正六边形,上图中正六边形A、正六边形C是一组。

然后通过横向间距,我们可以很好的得出当前的每组的宽度,然后比较。

(4)计算每个Item所处的Rect,这里的计算分成两个部分,一个是第一排的Rect,另一个是第二排的Rect,因为横向间距不同,这里做一个分别处理。

isItemInFirstLine是判断当前Item处于第一排还是第二排

第二排的Rect,这里有几个特点:

R:外接圆的半径

(1)left的参数 为(3/2*R + 横向间距。

(2)top的参数,就是上图的蓝线(形成三角形的中线)的Y轴位置 减去  R/2

(3)(2 * itemHeight+ mVerticalInterval) / 2) 求的就是蓝线的Y轴位置,相当于求一个中点。
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

(5)最后需要根据item的个数,计算出总的内容长度和高度为计算滑动边界做准备

 //设置总的宽度
        mTotalWidth = Math.max(mGroupWidth, getHorizontalSpace());
        //设置总的高度
        int totalHeight = (int) (getGroupSize() * itemHeight + (getGroupSize() - 1) * mVerticalInterval);
        //判断当前最后一组如果不是第一行,那么高度还得加上第一段偏移量
        //Y轴的第一段偏移量
        int itemOffsetHeight = (int) ((int) ((2 * itemWidth + mVerticalInterval) / 2) - 0.5 * itemWidth);
        if (!isItemInFirstLine(getItemCount() - 1)) {
            totalHeight += itemOffsetHeight;
        }
        //设置总的高度 取当前的内容 和 RecyclerView的高度的最大值
        mTotalHeight = Math.max(totalHeight, getVerticalSpace());
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

第三阶段 :布局阶段

这一阶段,最为核心的方法是layoutDecorated()方法,因为LayoutManager是通过这个方法,来给RecyclerView的子View进行布局操作的。

   /**
     * 第三阶段 - 布局阶段
     *
     * @param recycler
     * @param state
     */
    private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getItemCount() <= 0 || state.isPreLayout()) {
            return;
        }
        //考虑到当前RecyclerView会处于滑动的状态,所以这里的Rect的作用是展示当前显示的区域
        //需要考虑到RecyclerView的滑动量
        //mHorizontalOffset 横向的滑动偏移量
        //mVerticalOffset  纵向的滑动偏移量
        Rect displayRect = new Rect(0, mVerticalOffset,
                getHorizontalSpace(),
                getVerticalSpace() + mVerticalOffset);

        /**
         * 对这些View进行测量和布局操作
         */
        for (int i = 0; i < getItemCount(); i++) {
            Rect frame = mItemFrames.get(i);
            if (Rect.intersects(displayRect, frame)) {
                View scrap = recycler.getViewForPosition(i);
                addView(scrap);
                //测量子View
                measureChildWithMargins(scrap, 0, 0);
                //布局方法
                layoutDecorated(scrap, frame.left - mHorizontalOffset, frame.top - mVerticalOffset,
                        frame.right - mHorizontalOffset, frame.bottom - mVerticalOffset);
            }
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

这里displayRect 就是滑动过后显示的区域Rect,然后我们通过Rect.intersects()方法,判断当前的Rect,是否与displayRect相交,来判断当前的Item是否显示在当前的窗口上。

如果相交,然后测量View,将子View布局在RecyclerView上。

到这里,onLayoutChildren()已经重写完毕,现在可以运行可以查看,自定义的布局的状况,但是无法滑动,因为我们还没有给LayoutManager设置滑动的效果。


(3)设置RecyclerView的滑动效果

设置滑动效果,就比较固定了,有点类似模板的方法,如下:

    @Override
    public boolean canScrollVertically() {
        return true;
    }

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        detachAndScrapAttachedViews(recycler);
        //上边界判断
        if (mVerticalOffset + dy < 0) {
            dy = -mVerticalOffset;
            //下边界判断
        } else if (mVerticalOffset + dy > mTotalHeight - getVerticalSpace()) {
            dy = mTotalHeight - getVerticalSpace() - mVerticalOffset;
        }

        mVerticalOffset += dy;
        //使所有ChildView偏移 如果向上滑动 所有View就向下偏移 反之亦然
        offsetChildrenVertical(-dy);
        //重新布局
        fill(recycler, state);

        return dy;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

这里只希望RecyclerView竖向滑动,不希望横向滑动,所以只设置了canScrollVertically()为true。

而scrollVerticallyBy()中无非是三个步骤:

(1) 边界判断

(2) 记录滑动值,使子View偏移

(3) 重新根据滑动后的状态,进行布局
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

到此,LayoutManager已经自定义完毕了,顺着这个思路下来,可以发现,其实和第一部分介绍自定义LayoutManager一个模式,都是一些通用的流程,然后加上一些辅助计算的常量,就可以实现需要的效果。

其实自定义LayoutManager并没有那么难,了解了这些你也能写出属于你的LayoutManager。


PolygonItemView的自定义流程

大家读完上面,其实对自定义LayoutManager有一定印象了,那么直接给Item设置一张图片背景正常就可以结束了。这样只能说too young了。

点击区域重合


点击区域重合

通过上图大家可以发现,这里的点击区域发生了重合,这时候就会存在问题。所以这时候才会选择自定义View。

而且自定义View相比使用图片背景,会有很多优点,也可以高度定制。

1.可以定制正六边形的边框、颜色。
2.可以定制正六边形的大小。
3.可以定制正六边形的内的背景颜色。
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

PolygonItemView的初始化

    private void initData() {
        //初始化外边框的画笔
        mOuterPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mOuterPaint.setStyle(Paint.Style.STROKE);
        mOuterPaint.setStrokeWidth(mOuterWidth);
        mOuterPaint.setColor(mOuterColor);

        //初始化内侧的画笔
        mInnerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mInnerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mInnerPaint.setColor(mInnerColor);

        //判断点击事件是否在范围内的region
        mRegion = new Region();
        //绘制正六边形的path
        mViewPath = new Path();
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

在初始化的过程中,主要初始化各类的绘制参数,包括画笔,已经绘制路径的path,和判断其是否在点击区域内的Region。


绘制正六边形

    /**
     * 绘制多边形
     */
    public void lineMultShape(int count) {
        if (count < POLYGON_COUNT) {
            return;
        }
        mViewPath.reset();
        for (int i = 0; i < count; i++) {
            //当前角度
            int angle = 360 / count * i;
            if (i == 0) {
                mViewPath.moveTo(mCenterX + mRadius * MathUtil.cos(angle), mCenterY + mRadius * MathUtil.sin(angle));
            } else {
                mViewPath.lineTo(mCenterX + mRadius * MathUtil.cos(angle), mCenterY + mRadius * MathUtil.sin(angle));
            }
        }
        mViewPath.close();
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

正六边形

看了这样图,就可以很好的画出正六边形了,将360度分成6分,然后( mCenterX + R*cos α ,mCenterY + R * sinα)就是正六边形的每个端点的坐标,在用Path依次将其连接就可以了。


判断点击区域

其实自定义View,主要解决的是点击区域重合的问题,这里应该在dispatchTouchEvent()中拦截掉事件,然后判断其点击区域是否在正六边形内,来解决点击重合。

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            if (!isEventInPath(event)) {
                return false;
            }
        }
        return super.dispatchTouchEvent(event);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
    /**
     * 判断是否在多边形内
     *
     * @param event
     * @return
     */
    private boolean isEventInPath(MotionEvent event) {
        RectF bounds = new RectF();
        //计算Path的边界
        mViewPath.computeBounds(bounds, true);
        //将边界赋予Region中
        mRegion.setPath(mViewPath, new Region((int) bounds.left, (int) bounds.top,
                (int) bounds.right, (int) bounds.bottom));
        //判断 当前的触摸点是否在这个范围内
        return mRegion.contains((int) event.getX(), (int) event.getY());
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

到此,自定义View,也介绍完毕了。相比于自定义LayoutManager,自定义View更好理解一点。在这里如果有想法可以将正六边形换成其他的图形,完成属于你自己的创作。


HexagonRecyclerView后续的一些扩展方向

(1)从PolygonLayoutManager上,后续会提供多列的RecyclerView,通过设置参数来控制列数。

(2)在自定义View上,可能会提供设置图片的功能。

(3)后续可能会提供更多种不同形式的RecyclerView,其实原理都是类似的,更多的是创意。


参考文章

RecyclerView自定义LayoutManager,打造不规则布局

注意:这篇文章有一个BUG,博主可能没看回复,大家如果参考下,注意我在楼下的回复。

http://blog.csdn.net/qibin0506/article/details/52676670#reply