讀源碼,懂原理,有何用?寫業務代碼又用不到?—— 自定義換行容器控件

回想一下在做文本上寫做的場景,當從左到右寫滿一行後,會切換到下一行的開頭繼續寫。若是把「做文本」比做容器控件,把「字」比做子控件。Android 原生控件中沒有能「自動換行」的容器控件,若不斷向LinearLayout中添加View,它們會沿着一個方向不斷堆疊,即便實際繪製位置已經超出屏幕。git

在 GitHub 上找到了一些實現自動換行功能的容器控件,但使用過程當中發現略「重」,並且大部分都把「子控件選中狀態」和「自動換行」耦合在一塊兒。使得切換選中方式變得困難。「多控件間的選中模式」和「自動換行的容器控件」是兩個相互獨立的概念,分開實現這兩個功能會使得代碼能夠更加靈活地組合。「多控件間的選中模式」在以前的不再要和產品經理吵架了——Android自定義控件之單選按鈕中有詳細的介紹。github

本文試着從零開始手寫一個帶自動換行功能的容器控件。算法

這是 Android 視圖繪製系列文章的第三篇,系列文章目錄以下:bash

  1. View繪製原理——畫多大?
  2. View繪製原理——畫在哪?
  3. View繪製原理——畫什麼?
  4. 讀源碼,懂原理,有什麼用?寫業務代碼又用不到?—— 自定義換行容器控件

業務場景

自動換行容器控件的典型應用場景是:「動態多選按鈕」,即多選按鈕的個數和內容是動態變化的,這樣就不能把它們寫死在佈局文件中,而須要動態地調用addView()添加到容器控件中。 效果以下:app

自動換行容器控件

點擊一下 button 就會調用addView()向容器控件中添加一個 TextView 。dom

背景知識

若是瞭解「View繪製原理」中的測量和佈局的過程,就能垂手可得地自定義自動換行容器控件。這兩個過程的詳細介紹能夠分別點擊View繪製原理——畫多大?View繪製原理——畫在哪?ide

簡單回顧一下這一系列文章的結論:佈局

  • View 繪製包含三個步驟,依次是測量、佈局、繪製。它們分別解決了三個問題:畫多大?,畫在哪?,畫什麼?post

  • 「畫多大?」是爲了計算出控件自己的寬高佔用多少像素。對於容器控件來講就是「以不一樣方式排列的子控件的總寬高是多少像素。」學習

  • 「畫在哪?」是爲了計算出控件相對於屏幕左上角的相對位置。對於容器控件來講就是「如何安排每一個子控件相對於本身左上角的相對位置」。(當每一個控件相對於父控件都有肯定的位置時,只要遍歷完 View 樹,屏幕上全部控件的具體位置都得以肯定)

  • 容器控件用於組織若干子控件,因此它的主要工做是敦促全部子控件測量並佈局本身,它本身並不須要繪製圖案,因此自定義容器控件時不須要關心「畫什麼?」

重寫 onMeasure()

通過上面的分析,自定義自動換行容器控件只須要繼承ViewGroup並重寫onMeasure()onLayout()

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {}

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
}
複製代碼

本文將使用 Kotlin 做爲開發語音,Kotlin 可讀性超高,相信即便沒有學習過它也能看懂。關於 Kotlin 的語法細節和各類實戰能夠點擊這裏

對於容器控件來講,onMeasure()須要作兩件事情:

  1. 敦促全部子控件本身測量本身以肯定自身尺寸 。
  2. 計算出容器控件的尺寸。

好在ViewGroup中提供了一個方法來幫助咱們完成全部子控件的測量:

public abstract class ViewGroup extends View {

    protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
        final int size = mChildrenCount;
        final View[] children = mChildren;
        //'遍歷全部子控件並觸發它們本身測量本身'
        for (int i = 0; i < size; ++i) {
            final View child = children[i];
            if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
        
    protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
        final LayoutParams lp = child.getLayoutParams();
        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp.height);
        //'將父控件的約束和子控件的訴求相結合造成寬高兩個MeasureSpec,並傳遞給孩子以指導它測量本身'
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }
}
複製代碼

只有當全部子控件尺寸都肯定了,才能知道父控件的尺寸,就比如只有知道了全班每一個人的體重,才能知道全班的整體重。因此onMeasure()中應該首先調用measureChildren()

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        measureChildren(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
}
複製代碼

自動換行的容器控件的寬度應該是手動指定的(沒有固定寬度何來換行?)。而高度應該將全部控件高度相累加。因此在onMeasure()中需遍歷全部的孩子並累加他們的高度:

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        //'獲取容器控件的寬度(即佈局文件中指定的寬度)'
        val width = MeasureSpec.getSize(widthMeasureSpec)
        //'定義容器控件的初始高度爲0'
        var height = 0
        //'容器控件當前行剩下的空間'
        var remainWidth = width
        //'遍歷全部子控件並用自動換行的方式累加其高度'
        (0 until childCount).map { getChildAt(it) }.forEach { child ->
            val lp = child.layoutParams as LinearLayout.LayoutParams
            //'當前行已滿,在新的一行放置子控件'
            if (isNewLine(lp, child, remainWidth)) {
                remainWidth = width - child.measuredWidth
                //'容器控件新增一行的高度'
                height += (lp.topMargin + lp.bottomMargin + child.measuredHeight)
            } 
            //'當前行未滿,在當前行右側放置子控件'
            else {
                //'消耗當前行剩餘寬度'
                remainWidth -= child.measuredWidth
                if (height == 0) height = (lp.topMargin + lp.bottomMargin + child.measuredHeight)
            }
            //將子控件的左右邊距也考慮在內
            remainWidth -= (lp.leftMargin + lp.rightMargin)
        }
        //'控件測量的終點,即容器控件的寬高已肯定'
        setMeasuredDimension(width, height)
    }

    //'判斷是否須要新起一行:若是子控件寬度加上左右邊距大於當前行剩餘寬度,則需新起一行'
    private fun isNewLine(lp: LinearLayout.LayoutParams, child: View, remainWidth: Int) = lp.leftMargin + child.measuredWidth + lp.rightMargin > remainWidt
}
複製代碼

整個測量算法的目的是肯定容器控件的寬度和高度,關鍵是要維護好當前行剩餘空間remainWidth的值。測量過程的終點是View.setMeasuredDimension()的調用,它表示着容器控件尺寸已經有肯定值。

重寫 onLayout()

在肯定了容器控件及其全部子控件的尺寸後,下一步就是肯定全部子控件的位置:

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        //'當前橫座標(相對於容器控件左邊界的距離)'
        var left = 0
        //'當前縱座標(相對於容器控件上邊界的距離)'
        var top = 0
        //'上一行底部的縱座標(相對於容器控件上邊界的距離)'
        var lastBottom = 0
        //'遍歷全部子控件以肯定它們相對於容器控件的位置'
        (0 until childCount).map { getChildAt(it) }.forEach { child ->
            val lp = child.layoutParams as LinearLayout.LayoutParams
            //'新起一行'
            if (isNewLine(lp, child, r - l - left)) {
                left = -lp.leftMargin
                //'更新當前縱座標'
                top = lastBottom
                //'上一行底部縱座標置0,表示須要從新被賦值'
                lastBottom = 0
            }
            //'子控件左邊界'
            val childLeft = left + lp.leftMargin
            //'子控件上邊界'
            val childTop = top + lp.topMargin
            //'肯定子控件上下左右邊界相對於父控件左上角的距離'
            child.layout(childLeft, childTop, childLeft + child.measuredWidth, childTop + child.measuredHeight)
            //'更新上一行底部縱座標'
            if (lastBottom == 0) lastBottom = child.bottom + lp.bottomMargin
            left += child.measuredWidth + lp.leftMargin + lp.rightMargin
        }
    }

    //'判斷當前子控件是否應該放置在新一行'
    private fun isNewLine(left: Int, lp: LinearLayout.LayoutParams, child: View, parentWidth: Int) = 
        left + lp.leftMargin + child.measuredWidth + lp.rightMargin > parentWidth

}
複製代碼

子控件的位置使用它上下左右四個點相對於父控件左上角的距離來描述。因此肯定全部子控件位置的算法關鍵是維護好當前插入位置的橫縱座標,每一個子控件的位置都是在當前插入位置上加上本身的寬高來肯定的。

容器控件調用child.layout()觸發子控件定位本身,子控件最終會調用setFrame()以最終肯定本身相對於父控件的位置。

public class View {
    public void layout(int l, int t, int r, int b) {
        ...
        //'調用setFrame()'
        boolean changed = isLayoutModeOptical(mParent) ?setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
        ...
    }
    
    protected boolean setFrame(int left, int top, int right, int bottom) {
            ...
            //'爲上下左右賦值'
            mLeft = left;
            mTop = top;
            mRight = right;
            mBottom = bottom;
            ...
    }
}
複製代碼

如今就能夠像這樣來使用LineFeedLayout了:

class MainActivity : AppCompatActivity() {
    private var index = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //'當點擊按鈕是動態添加TextView到自動換行容器控件中'
        btnAdd.setOnClickListener {
            //'構建TextView'
            TextView(this).apply {
                text = 」Tag ${index}「
                textSize = 20f
                setBackgroundColor(Color.parseColor(」#888888「))
                gravity = Gravity.CENTER
                setPadding(8, 3, 8, 3)
                setTextColor(Color.parseColor(」#FFFFFF「))
                layoutParams = LinearLayout.LayoutParams(
                    LinearLayout.LayoutParams.WRAP_CONTENT,
                    LinearLayout.LayoutParams.WRAP_CONTENT
                ).apply {
                    rightMargin = 15
                    bottomMargin = 40
                }
            //'將TextView動態添加到容器控件container中'
            }.also { container?.addView(it) }
            index++
        }
    }
}
複製代碼

若是但願子控件之間存在多選、單選、菜單選,這類互斥選中關係,能夠將 demo 中的 TextView 替換成 自定義控件Selector,關於該控件的介紹詳見不再要和產品經理吵架了——Android自定義控件之單選按鈕

Talk is cheap, show me the code

完整代碼能夠點擊這裏

相關文章
相關標籤/搜索