Android自定義View系列:佈局自定義

1 View的框架

View的框架

2 View的整體繪製流程

View的繪製都是由ViewRoot(對應 ViewRootImpl)來完成,Window和DecorView通過ViewRootImpl關聯。

View繪製的工作流程

Activity對象被創建 -> DecorView被設置到Window中,創建ViewRootImpl關聯DecorView(間接關聯Window)-> 執行ViewRoot的 performTraversals()

  • performMeasure() -> measure() 先繪製DecorView,再執行 onMeasure() 遍歷繪製ViewGroup和子View

  • performLayout() -> layout() 先繪製DecorView,再執行 onLayout() 遍歷繪製ViewGroup和子View

  • performDraw()-> dispatchDraw() 先繪製DecorView,再執行 onDraw() 遍歷繪製ViewGroup和子View

3 MeasureSpec

3.1 獲取測量大小和測量模式

在佈局過程中,經常會使用到 MeasureSpec 根據 onMeasure(int widthMeasureSpec, int heightMeasureSpec) 提供的兩個參數 widthMeasureSpecheightMeasureSpec 獲取測量的寬高和測量模式,或者生成 widthMeasureSpecheightMeasureSpec

其中,widthMeasureSpecheightMeasureSpec 是一個32位的int值,高2位是測量模式specMode,低30位是測量大小specSize。

寬高和測量模式使用如下方法獲取和設置:

  • MeasureSpec.getMode(widthMeasureSpec/heightMeasureSpec):獲取測量模式specMode

  • MeasureSpec.getSize(widthMeasureSpec/heightMeasureSpec):獲取測量大小specSize

  • MeasureSpec.makeMeasureSpec(int size, int mode):根據自己設置的測量大小和測量模式生成一個 widthMeasureSpecheightMeasureSpec

3.2 三種測量模式

  • EXACTLY:精確值模式,對應將控件的 layout_width 屬性或 layout_height 屬性指定爲具體數值時,比如android:layout_width=”100dp”,或者指定爲 match_parent(View類的 onMeasure() 默認的模式,所以需要指定 wrap_content 就要重寫 onMeasure()

  • AT_MOST:最大值模式,對應將控件的 layout_width 屬性或 layout_height 屬性指定爲 wrap_content 時,此時控件的最大尺寸只要不超過父控件允許的最大尺寸即可

  • UNSPECIFIED:不指定寬高,要多大就多大,一般用在系統內部的measure,不會經常使用

4 佈局過程

4.1 佈局過程的含義

佈局過程,就是程序在運行時利用佈局文件的代碼來計算出實際尺寸的過程。

4.2 佈局過程的工作內容

兩個階段:測量階段和佈局階段。

  • 測量階段:從上到下遞歸地調用每個View或者ViewGroup的 measure() 方法,測量它們的尺寸並計算它們的位置

  • 佈局階段:從上到下調用每個View或者ViewGroup的 layout() 方法,把測得的它們的尺寸和位置賦值給它們

4.3 View或ViewGroup的佈局過程

  • 測量階段:measure() 方法被父View調用,在 measure() 中做一些準備和優化工作後,調用 onMeasure() 來進行實際的自我測量。onMeasure() 做的事,View和ViewGroup不一樣:

    • View:View在 onMeasure() 中會計算出自己的尺寸然後保存

    • ViewGroup:ViewGroup在 onMeasure() 中會調用所有子View的 measure() 讓它們進行自我測量,並根據子View計算出期望尺寸來計算出它們的實際尺寸和位置(實際上99%的父View都會使用子View繪製出的期望尺寸來作爲實際尺寸)然後保存。同時,它也會根據子View的尺寸和位置來計算出自己的尺寸然後保存

在這裏插入圖片描述

  • 佈局階段:layout() 方法被父View調用,在 layout() 中它會保存父View傳進來的自己的位置和尺寸,並且調用 onLayout() 來進行實際的內部佈局。onLayout() 做的事,View和ViewGroup也不一樣:

    • View:由於沒有子View,所以View的 onLayout() 什麼也不做

    • ViewGroup:ViewGroup在 onLayout() 中會調用自己的所有子View的 layout() 方法,把它們的尺寸和位置傳給它們,讓它們完成自我的內部佈局

在這裏插入圖片描述

在這裏插入圖片描述

4.4 佈局過程自定義的方式

有三種自定義方式:

  • 重寫 onMeasure() 來修改已有的View的尺寸

  • 重寫 onMeasure() 來全新定製自定義View的尺寸

  • 重寫 onMeasure()onLayout() 來全新定製自定義ViewGroup的內部佈局

4.4.1 重寫onMeasure修改已有的View的尺寸

  • 重寫 onMeasure() 方法,並在裏面調用 super.onMeasure(),觸發原有的自我測量

  • super.onMeasure() 的下面用 getMeasureWidth()getMeasureHeight() 來獲取之前的測量結果,並使用自己的算法,根據測量結果計算出新的結果

  • 調用 setMeasureDimension() 來保存新的結果

public class SquareImageView extends ImageView {
	...

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// 先執行原測量算法
		// super.onMeasure()也是調用了setMeasureDimension()
		super.onMeasure(widthMeasureSpec, heightMeasureSpec); 

		// 獲取原先的測量結果
		int measureWidth = getMeasureWidth();
		int measureHeight = getMeasureHeight();

		// 利用原先的測量結果計算出尺寸
		int size = Math.min(measureWidth, measureHeight);

		// 保存計算後的結果
		setMeasureDimension(size, size);
	}

	// 如果重寫layout方法,你發現其實也是可以實現效果的,但是我們爲什麼不在這裏寫,要在onMeasure()呢?
	// 因爲在onMeasure()的時候保存的測量尺寸在父View是可知的,父View會根據你測量的尺寸來佈局你的位置
	// 但是在layout的時候父View就是不可知的了,這會導致當父View內有多個子View,你在layout又重寫了自己的大小
	// 而父View仍舊按照你在onMeasure()時測量的尺寸來擺放你的位置,就會出現佈局錯誤
//	@Override
//	public void layout(int l, int t, int r, int b) {
//		int width = r - l;
//		int height = b - t;
//		int size = Math.min(width, height);
//		super.layout(l, t, l + size, t + size);
//	}
}

4.4.2 重寫onMeasure()來全新定製自定義View的尺寸

  • 重寫 onMeasure() 把尺寸計算出來

  • 計算出自己的尺寸

  • resolveSize() 或者 resolveSizeAndState() 修正結果

  • 使用 setMeasuredDimension() 保存結果

public class CircleView extends View {
	private static final float RADIUS = Utils.dpToPx(80);
	private static final float PADDING = Utils.dpToPx(30);

	Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

	// onMeasure()方法的widthMeasureSpec和heightMeasureSpec有特殊的含義
	// 從上面我們知道,子View的測量是父View發起measure()然後讓子View從上到下遞歸自測量
	// 而widthMeasureSpec和heightMeasureSpec就是父View提供給子View的一個測量寬高最大限度
	// widthMeasureSpec和heightMeasureSpec對於子View來說,就是處理自己在xml佈局設置的layout_width和layout_height

	// 自定義子View最主要的就是處理wrap_content也就是MeasureSpec.AT_MOST的情況
	// 在AT_MOST情況將自己的想要的大小和父view限定給我們的大小int size = MeasureSpec.getSize(measureSpec)對比符合預期
	// AT_MOST自適應大小,如果想要的大小超過父view的大小就使用限定大小size,否則用我們自己的大小
	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// 自己定義的view寬高
		int size (int) (PADDING + RADIUS) * 2);
		// 將子View測量的寬高大小過濾一遍,讓寬高符合父View的要求
		// resolveSize() sdk已經提供該方法
		int width = resolveSize(measureWidth, widthMeasureSpec);
		int height = resolveSize(measureHeight, heightMeasureSpec);

		setMeasureDimension(width , height );
	}

	@Override
	protected void onDraw(Canvas canvas) {
		super.onDraw(canvas);

		canvas.drawColor(Color.RED);
		canvas.drawCircle(PADDING + RADIUS, PADDING + RADIUS, RADIUS, paint);
	}

	// 注:下面這段代碼已經簡化過,不是resoveSize()的源碼
//	public static int resolveSize(int size, int measureSpec) {
//		final int specMode = MeasureSpec.getMode(measureSpec);
//		final int specSize = MeasureSpec.getSize(measureSpec);

//		switch(specMode) {
			// 父View提供的specMode爲不限定寬高,返回子View子測量寬高
//			case MeasureSpec.UNSPECIFIED:
//				return size;
			// 父View提供的specMode爲自適應,需要特殊處理
//			case MeasureSpec.AT_MOST:
				// 如果子View測量寬高比父View提供最大限度的寬高要小,使用子View測量的寬高
//				if (size <= specSize) {
//					return size;
//				} 
				// 否則使用父View提供的最大限度寬高
//				else {
//					return specSize;
//				}
			// 父View提供的specMode爲精確值,直接返回父View提供的寬高
//			case MeasureSpec.EXACTLY:
//				return specSize;
//			default:
//				return size;
//		}
//	}
//}

問題:getWidth()getHeight()getMeasureWidth()getMeasureHeight() 的區別?

getWidth()getHeight() 是確定了View的最終寬高,形成於layout過程;getMeasureWidth()getMeasureHeight() 是確定了View的測量寬高,形成於measure過程。

一般情況下最終寬高和測量寬高几乎相同。除非重寫了layout方法導致最終寬高與測量寬高不同。

5 定製Layout的內部佈局

步驟:

  • 重寫 onMeasure()

    • 遍歷每個子View,用 measureChildWidthMargins() 測量子View

      • MarginLayoutParamsgenerateLayoutParams()

      • 有些子View可能需要重新測量

      • 測量完畢後,得出子View的實際位置和尺寸,並暫時保存

    • 測量出所有子View的位置和尺寸後,計算出自己的尺寸,並用 setMeasureDimension() 保存

  • 重寫 onLayout()

    • 遍歷每個子View,調用它們的 layout() 來將位置和尺寸傳給它們
public class TagLayout extends ViewGroup {
    private List<Rect> childRectBounds = new ArrayList<>();

    public TagLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthUsed = 0; // 已使用父View的寬度,按最大的子View寬度來就行了
        int heightUsed = 0; // 已使用父View的高度,每一行最大子View高度的疊加和
        int lineWidthUsed = 0; // 當前行的已用寬度
        int lineHeight = 0; // 當前行最大子View的高度
        
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);

            // 根據父View的MeasureSpec限定大小以及子View的LayoutParams共同決定去測量子View的大小
            // 這句代碼的具體實現要對照下表
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);

            // 父View的MeasureSpec不能是UNSPECIFIED無限大,當前已用寬度+下一個測量子View的寬度 > 父View的限定寬度,換行
            if (widthMode != MeasureSpec.UNSPECIFIED && ((lineWidthUsed + child.getMeasuredWidth()) > widthSize)) {
                // 當前行的已用寬度置0
                lineWidthUsed = 0;
                // 已使用父View的高度增加爲當前行的最大子View的高度
                heightUsed += lineHeight;
                // 重新計算
                measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
            }

            // 測量後保存子View的擺放位置,每一行因子View標籤文本不同而擺放數量不同
            Rect childRect;
            if (childRectBounds.size() <= i) {
                childRect = new Rect();
                childRectBounds.add(childRect);
            } else {
                childRect = childRectBounds.get(i);
            }
            // left:添加一個子View後,上一個子View的寬度就是下一個子View的起始位置
            // top:其實就是每一行最大的View高度作爲下一行的起始高度
            // right:left+當前View的寬度
            // bottom:top+當前View的高度
            childRect.set(lineWidthUsed, heightUsed, lineWidthUsed + child.getMeasuredWidth(),
                    heightUsed + child.getMeasuredHeight());

            lineWidthUsed += child.getMeasuredWidth(); // 該行添加子View的寬度方便下一個子View的位置擺放計算
            widthUsed = Math.max(lineWidthUsed, widthUsed);
            lineHeight = Math.max(lineHeight, child.getMeasuredHeight()); // 該行最大子View高度,換行時使用
        }

        int measureWidth = widthUsed;
        heightUsed += lineHeight;
        int measureHeight = heightUsed;
        setMeasuredDimension(measureWidth, measureHeight);
    }

    // 使用使用measureChildWithMargins()時,內部會獲取到childView的LayoutParams並將它強轉爲MarginLayoutParams
    // 需要使用該方法轉換,否則拋出ClassCastException
    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // 只需要根據測量的結果循環擺放子View的位置即可
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            Rect rect = childRectBounds.get(i);
            child.layout(rect.left, rect.top, rect.right, rect.bottom);
        }
    }
}

上面的計算方式可以通過一個表格來說明:

自定義ViewGroup的MeasureSpec與父容器的MeasureSpec和自身的LayoutParams決定:

在這裏插入圖片描述

  • 子View是dp/dx(即固定寬高),不管父容器的MeasureSpec是什麼,View的MeasureSpec都是 EXACTLY,大小遵循LayoutParams中的大小

  • 子View是 match_parent(即寬高爲 match_parent),如果父容器是 EXACTLY,子View也是 EXACTLY,如果父容器是AT_MOST,子View也是 AT_MOST;大小是父容器的剩餘空間

  • 子View是 wrap_content(即寬高爲 wrap_content),不管父容器的MeasureSpec是什麼,子View的MeasureSpec都是AT_MOST,大小不能超過父容器的剩餘空間

上面的規則其實就是通過一個方法 measureChildWidthMargins() 完成。

關於保存子View位置的兩點說明:

  • 不是所有的Layout都需要保存子View的位置(因爲有的Layout可以在佈局階段實時推導出子View的位置,例如LinearLayout)

  • 有時候對某些子View需要重複測量兩次或多次才能得到正確的尺寸和位置

6 自定義View注意事項

  • 讓View支持wrap_content:如果是直接繼承View或ViewGroup,且自定義View的寬高有wrap_content,需要重寫onMeasure()進行處理

  • 如果有必要,讓自定義View支持padding:如果是直接繼承View,要在 onDraw() 中處理padding(margin在父容器會處理),否則無法起作用;如果是直接繼承ViewGroup,要在 onMeasure()onLayout() 中處理padding和margin,否則無法起作用;getPaddingLeft()getPaddingTop()getPaddingRight()getPaddingBottom() 獲取padding

  • 儘量不要在自定義View中使用Handler,沒必要:View內部提供了post方法可以替代Handler的作用,除非明確需要Handler發送消息

  • 自定義View中如果有線程或動畫,要及時停止:如果自定義View中有線程或動畫,在 onDetachedFromWindow() 停止線程或動畫(Activity退出或當前View被remove時調用,onAttachedToWindow()相反);不及時處理容易造成內存泄漏

  • 自定義View帶有滑動嵌套情形時,要處理滑動衝突

發佈了199 篇原創文章 · 獲贊 7 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章