ItemDecoration实现等分间距

一.背景

远古时代,GridView 和 ListView 可以直接使用其自带的 api 设置 item 之间的分割线,通过修改分割线的粗细和颜色等可以轻松实现分割线和间距类的效果,还有的直接通过在 item 的布局里设置 margin 或 padding 来实现,后来有了 RecyclerVIew,但是却没提供设置分割线的 api,不过提供了一个功能丰富的 ItemDecoration 类,这个类首先能实现的最简单的功能就是分割线了,这里先来记录一下关键方法 getItemOffsets 相关用法,里面的 outRect 容易理解错。
ItemDecoration实现等分间距_第1张图片

二.方案

tv 端应用里有很多这种间距平分的页面,如果不做任何处理,一般你会把 item 宽高设置固定值,但是因为没控制间距,总会出现间距不平分,item 会往左靠,最右边可能会有一部分空白,各 item 间左右会有间距,但是并不是你代码设置的,想修改也没地方改,这样肯定达不到设计图上的效果。

推荐方式(设计图尺寸是1920*1080):
item 宽度设置 match_parent,根据UI图上的 item 间距尺寸和距离屏幕边缘的尺寸来控制页面的尺寸,例如上图中(中间那部分黄色背景的RecyclerView)每个 item 之间的间距是48px,item 宽高分别为 250px,331px,距离父元素 RecyclerView 左右 90px,上 24px,下 36px,拿到这种设计图,可以这样来搞:
1.不用关心 item 的宽,只需要关心高和间距,以及距离父元素的距离;
2.通过 ItemDecoration 来设置 item 之间的间距;
3.不用 ItemDecoration 设置 item 距离父元素 RecyclerView 的距离,而是通过父元素自己设置 padding 来控制;
这样可以精细的控制 UI 效果,例如设置完间距后,间距的总宽度是 48*5+90*2 = 420,剩下的会因 item 宽度设置 match_parent 平分成 6 份 (1920-420)/6 = 250px,正好和设计图中完全对上。

三.实现

3.1先按方案中第一步的思路来设置 item 布局:


<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#c2b8b8"
    android:focusable="true"
    >
    <ImageView
        android:id="@+id/image"
        android:layout_width="match_parent"
        android:layout_height="@dimen/px_331"
        android:src="@mipmap/ic_img"
        android:clipChildren="false"
        android:scaleType="fitXY"/>
    <TextView
        android:id="@+id/textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/image"
        android:layout_marginEnd="@dimen/px_12"
        android:layout_marginStart="@dimen/px_12"
        android:layout_marginTop="@dimen/px_2"
        android:textColor="#333333"
        android:textSize="@dimen/px_31"
        />
RelativeLayout>

3.2自定义 ItemDecoration 实现间距:
后面全拿垂直的风格布局来说,这里有个容易理解错的地方,getItemOffsets 方法里的 outRect 可以理解成是包在 item 布局外面的一个参与 item 测量的框,不能理解成是每个 item 之间的只用来占位的一个 rect,如果理解错误会导致这样一种思路,如果是第一列,就设置 outRect.left=0,其它全部只设置 outRect.left=mSpace,outRect.right=0,或者全设置右边等于mSpace,左边等于0,最后一列右边等于0,程序运行起来会出现第一列的 item 的宽度都比其它的 item 宽的情况:

	@Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
                               @NonNull RecyclerView.State state) {
        ...
        int position = parent.getChildAdapterPosition(view);
        int column = position % spanCount;

        if (column == 0) { // 如果是第一列,不设置左边间距
            outRect.left = 0;
        } else { // 其它列全设置左边的间距
            outRect.left = mSpace;
        }
    }

ItemDecoration实现等分间距_第2张图片
上图效果就能很好的说明了 outRect 是包在 item 外的一层偏移,第一列 item 尺寸问题可在源码中找到原因:

    Rect getItemDecorInsetsForChild(View child) {
        ...
        final Rect insets = lp.mDecorInsets;
        insets.set(0, 0, 0, 0);
        final int decorCount = mItemDecorations.size();
        for (int i = 0; i < decorCount; i++) {
            mTempRect.set(0, 0, 0, 0);
            mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);
            insets.left += mTempRect.left;
            insets.top += mTempRect.top;
            insets.right += mTempRect.right;
            insets.bottom += mTempRect.bottom;
        }
        lp.mInsetsDirty = false;
        return insets;
    }
...
    public void measureChildWithMargins(View child, int widthUsed, int heightUsed) {
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();

        final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
        widthUsed += insets.left + insets.right;
        heightUsed += insets.top + insets.bottom;

        final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
                                                  getPaddingLeft() + getPaddingRight()
                                                  + lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
                                                  canScrollHorizontally());
        final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
                                                   getPaddingTop() + getPaddingBottom()
                                                   + lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
                                                   canScrollVertically());
        if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
            child.measure(widthSpec, heightSpec);
        }
	}

上面代码 29 行,获取自定义 ItemDecoration 中设置的 outRect,然后传到33行的 getChildMeasureSpec() 方法中:

public static int getChildMeasureSpec(int parentSize, int parentMode, int padding,
                int childDimension, boolean canScroll) {
            int size = Math.max(0, parentSize - padding);
            int resultSize = 0;
            int resultMode = 0;
    ...
            if (childDimension >= 0) {
                    resultSize = childDimension;
                    resultMode = MeasureSpec.EXACTLY;
                } else if (childDimension == LayoutParams.MATCH_PARENT) {
                    resultSize = size;
                    resultMode = parentMode;
                } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                    resultSize = size;
                    if (parentMode == MeasureSpec.AT_MOST || parentMode == MeasureSpec.EXACTLY) {
                        resultMode = MeasureSpec.AT_MOST;
                    } else {
                        resultMode = MeasureSpec.UNSPECIFIED;
                    }
                }
    ...
            //noinspection WrongConstant
            return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
        }

从第三行看到 size 赋的是 parentSize 减去传进来的 padding 来作为 item 的宽度不设固定值情况下的最大范围,由于第一列的 outRect 未设置值,所以这一列的 item 可分配的最大值比其它列多出了 outRect.left 的值,就造成了图中的错误效果。

正确的方式:
实际的结构如下图:
ItemDecoration实现等分间距_第3张图片
图中 insets 就是 getItemOffsets 方法中的 outRect;
上面从源码中也看到了这个 outRect 和 item 尺寸是有关系的,那么就可以按这个原则来搞,让所有的 outRect 的尺寸保持一致,例如所有 item的 outRect.left + outRect.right相等,outRect.top + outRect.bottom 相等;

按这个思路再想让所有的 item 间距平分,就需要分析计算一下了,下面都只拿横向平分来说;

计算这些值需要分两种情况,第一种是第一列和最后一列离两边的无边距,第二种是有边距,这里不考虑第二种情况,个人觉得这个边距由RecyclerView 设置 padding 或 margin 来控制更合适,使用 outRect 是可以控制,但是就感觉像是设置一个垂直LinearLayout 里的每一个子布局的 margin_left 和 margin_right 一样,为何不统一设置到 LinearLayout 的 padding_left 和 padding_right 上呢。

下面来看无边距的情况,按设计图尺寸,space = 48px,那么各 item 的 outRect.left + ourRect.right = 48 * 5 / 6 = 40px:

position=0:left 0,right 40
position=1:left 8,right 32
position=2:left 16,right 24

position=5:left 40,right 0

每个 item 的 outRect 除了第一列的左边,其它的左边等于 40px 减去前一个 item 的 outRect 的右边,右边等于 40px 减去自己的左边,就可以写成下面这样:

int position = parent.getChildAdapterPosition(view);
int column = position % spanCount;

int totalSpace = mSpace * (spanCount - 1);// 48*5=240
int itemSpace = totalSpace / spanCount;// 240/6=40
int the = mSpace - itemSpace;// 等差数列的值 48 - 40 = 8

outRect.left = column*the;// 0, 8, 16, 24
outRect.right = itemSpace-column*the;

然后把上面的计算一顿合并同类项,最终变成如下代码:

package com.lcp.tvtest;

import android.graphics.Rect;
import android.support.annotation.NonNull;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.StaggeredGridLayoutManager;
import android.view.View;

/**
 * Created by Aislli on 2019/10/24 0024.
 */
public class GridItemDecoration extends RecyclerView.ItemDecoration {
    private int mSpace = 20;

    public GridItemDecoration(int space) {
        this.mSpace = space;
    }

    @Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, 
                               @NonNull RecyclerView.State state) {
        int spanCount;
        if (parent.getLayoutManager() instanceof StaggeredGridLayoutManager) {
            StaggeredGridLayoutManager manager = (StaggeredGridLayoutManager) parent.getLayoutManager();
            spanCount = manager.getSpanCount();
        } else if (parent.getLayoutManager() instanceof GridLayoutManager) {
            GridLayoutManager manager = (GridLayoutManager) parent.getLayoutManager();
            spanCount = manager.getSpanCount();
        } else {
            spanCount = 1;
        }
        int position = parent.getChildAdapterPosition(view);
        int column = position % spanCount;

        outRect.left = column * mSpace / spanCount;
        outRect.right = mSpace - (column + 1) * mSpace / spanCount;

        if (position >= spanCount) {
            outRect.top = mSpace;
        }
    }
}

再设置 parent 的 padding 达到设置图中四边间距的效果


<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ListActivity">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#66e6e675"
        android:clipToPadding="false"
        android:paddingBottom="@dimen/px_36"
        android:paddingEnd="@dimen/px_90"
        android:paddingStart="@dimen/px_90"
        android:paddingTop="@dimen/px_24"
        >
        
    android.support.v7.widget.RecyclerView>
RelativeLayout>

Activity中的测试代码:

public class ListActivity extends AppCompatActivity {

    private RecyclerView recyclerView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_list);
        recyclerView = findViewById(R.id.recycler);

        ArrayList<String> strings = new ArrayList<>();
        for (int i = 0; i < 12; i++) {
            strings.add("i:" + i);
        }

        GridItemDecoration gridItemDecoration = new GridItemDecoration((int) getResources().getDimension(R.dimen.px_48));
        GridLayoutManager gridLayoutManager = new GridLayoutManager(this, 6);
        ListAdapter listAdapter = new ListAdapter(strings);

        recyclerView.setLayoutManager(gridLayoutManager);
        recyclerView.addItemDecoration(gridItemDecoration);
        recyclerView.setAdapter(listAdapter);
    }
}

这里就实现文中开始的第二张效果图样式了,去掉 RecyclerView 的 padding 就是第一张图的样式,其它方向的间距设置,原理和上面一致。

你可能感兴趣的:(Android)