150行代码实现自定义九宫格ViewGroup

引言

九宫格展现图片是不少APP的经常使用功能,固然实现方式有不少种。这里我们选择自定义ViewGroup来实现。作一个抛砖引玉的效果,理解自定义ViewGroup的经常使用流程。markdown

分析

首先分析九宫格的基本布局逻辑:app

  • 当只有1张图片的时候,布局中的ImageView会根据图片自己的宽高比呈现为横图或竖图
  • 当大于1张的时候,布局中的ImageView的宽高会固定为布局宽度的(减去了图片之间的间距)1/3大小,并呈3*3现网格布局。
  • 特殊状况当有4张图片的时候,图片View的宽高会固定为布局宽度的(减去了图片之间的间距)1/3大小,但网格只有两列。

代码实现

图片实体

从上面的分析,咱们能够发现当只有一张图片的时候,为了肯定ImageView的大小,须要知道图片的宽高,那么首先定义一个图片接口:ide

interface GridImage {

    //图片的宽
    fun getWidth(): Int

    //图片的高
    fun getHeight(): Int

    //图片地址
    fun getUri(): Uri?
}
复制代码
ViewGroup实现

新建一个GridImageLayout类继承自ViewGroup,重写onMeasure方法和onLayout方法。布局

首先定义几个变量方便后续工做:this

private val data = mutableListOf<GridImage>()//数据
    private var lineCount = 0 //展现所有数据须要的行数
    private val maxCount = 9 //最大支持图片数
    private val maxRowCount = 3 //最多列数
    private var space = 0 //图片之间的间距
复制代码
测量大小

自定义一个ViewGroup的首要任务就是要定义测量逻辑,让ViewGroup知道本身的大小,才能在屏幕上展现出来。 根据上面的分析得出:编码

当图片只有一张的时候,整个ViewGroup的大小和负责显示图片的ImageView是同样大的。这个大小能够根据图片的宽高比乘以一个预设的宽度或高度获得。这个预设的宽度取决于xml文件里设定或根据UI需求本身定义。spa

而当有多张图片的时候,宽度有两种状况须要考虑:code

  • 在xml文件定义为Wrap_Content模式,宽度根据实际展现的列数乘以每列的宽度
  • 在xml文件中固定数值Match_Parent模式,宽度直接设定为系统测量到的值

但其实不多有使用Wrap_Content模式的场景,因此这里不考虑。orm

除了须要肯定自身的大小觉得,还须要肯定每一个子View的大小。子View大小逻辑在分析中已经能够得出。xml

理清逻辑后,则编码的工做就简单了。代码以下:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        if (data.isEmpty())
            super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY))
        else {
            val groupWidth: Float
            val groupHeight: Float
            val size = data.size
            if (size == 1) {
                //当图片只有1张的时候,最大宽为当前ViewGroup的宽80%,最大高定义为200dp
                val maxWidth = MeasureSpec.getSize(widthMeasureSpec) * 0.8f
                val maxHeight = TypedValue.applyDimension(
                    TypedValue.COMPLEX_UNIT_DIP,
                    200f,
                    resources.displayMetrics
                )
                //可自由定制
                val minWidth = maxWidth * 0.8f
                val minHeight = maxHeight * 0.8f
                val image = data.first()
                val ratio = image.getWidth() / image.getHeight().toFloat()
                val childWidth: Float
                val childHeight: Float
                if (ratio > 1) {
                    childWidth = min(maxWidth, max(minWidth, image.getWidth().toFloat()))
                    childHeight = childWidth / ratio
                } else {
                    childHeight = min(maxHeight, max(minHeight, image.getHeight().toFloat()))
                    childWidth = childHeight * ratio
                }
                measureChild(childWidth.toInt(), childHeight.toInt())
                groupWidth = childWidth
                groupHeight = childHeight
            } else {
                //若是是大于两个,则child宽高为当前ViewGroup宽度的1/3
                val childWidth =
                    (MeasureSpec.getSize(widthMeasureSpec) -
                            (space * (maxRowCount - 1))) / maxRowCount.toFloat()
                measureChild(childWidth.toInt(), childWidth.toInt())
                groupWidth = MeasureSpec.getSize(widthMeasureSpec).toFloat()
                groupHeight = (childWidth * this.lineCount) + (space * (this.lineCount - 1))
            }
            setMeasuredDimension(
                MeasureSpec.makeMeasureSpec(groupWidth.toInt(), MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(groupHeight.toInt(), MeasureSpec.EXACTLY)
            )
        }
    }

    private fun measureChild(childWidth: Int, childHeight: Int) {
        for (i in 0 until data.size) {
            val child = getChildAt(i) ?: continue
            child.measure(
                MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY)
            )
        }
    }
复制代码
布局

测量完成后,知道了自身和子View的大小,那么就须要肯定子View该怎么排列的问题。九宫格的布局比较规律,是比较好实现的,每列最多3个view,最多3排,我们使用一个for循环就搞定了。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        if (data.isEmpty())
            return
        for (i in 0 until data.size) {
            val child = getChildAt(i)
            val childWidth = child.measuredWidth
            val childHeight = child.measuredHeight
            val currentRowIndex = i % maxRowCount
            val currentLineIndex = i / maxRowCount
            val marginLeft = if (currentRowIndex == 0) 0 else this.space
            val marginTop = if (currentLineIndex == 0) 0 else this.space
            val left = currentRowIndex * childWidth + marginLeft * currentRowIndex
            val top = currentLineIndex * childHeight + marginTop * currentLineIndex
            child.layout(left, top, left + childWidth, top + childHeight)
        }

    }
复制代码
设置数据并添加子View

上面两个方法写完后,就已经完成了90%了。可是我们如今尚未真正往里添加ImageView,如今暴露一个方法,设置数据并添加ImageView

//loadCallback 是加载图片的回调,由调用者实现加载图片的功能。
  fun setData(
        data: List<GridImage>,
        loadCallback: (index: Int, view: ImageView, image: GridImage) -> Unit
    ) {
        removeAllViewsInLayout()
        this.data.clear()
        if (data.size > maxCount) {
            this.data.addAll(data.subList(0, maxCount))
        } else {
            this.data.addAll(data)
        }
        this.lineCount = ceil(data.size / maxRowCount.toFloat()).toInt()
        for (i in data.indices) {
            val imgView = ImageView(context)
            addViewInLayout(
                imgView, i, LayoutParams(
                    LayoutParams.WRAP_CONTENT,
                    LayoutParams.WRAP_CONTENT
                )
            )
            loadCallback(i, imgView, data[i])
        }
        requestLayout()
    }
复制代码

最后开放自定义xml属性,定义间距之类的,达到可在xml文件中自定义。

效果以下

image.png

至此,一个九宫格布局就已经实现了,是否是很简单呢。 其实不管是自定义ViewGroup仍是自定义View,重点都是先理清其中的逻辑,再编写代码。