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,重點都是先理清其中的邏輯,再編寫代碼。