View的繪製流程
ViewGroup的職能是為childView計算出建議的寬高和測量模式。View的職能是根據父容器建議的寬高計算並繪製出自身形態。
每個ViewGroup都有獨特的LayoutParams,用於確定childView支援哪些屬性,如LinearLayout的layout_weight屬性。在XML佈局裡,凡是以layout、margin開頭的屬性,都是針對容器的。如layout_width、layout_gravity等。
有關自定義屬性可參考 寫一個擴充套件性強的自定義控制元件
View的繪製需要經過measure-layout-draw三個過程才能將View繪製出來。measure負責測量view的寬高,layout負責確定View在父容器中位置,draw負責將view繪製在螢幕上。
onMeasure
MeasureSpec是一個32位的int值,裡面包含測量模式SpecMode和測量值SpecSize。在onMeasure方法裡,父容器為子元素指定了寬、高的MeasureSpec。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); }
View的測量模式
- EXACTLY :表示設定了精確值。如childView設定了寬高為精確值或match_parent。
- AT_MOST :表示childView被限制在一個最大值內(父容器的剩餘空間)。如childView設定寬高為 wrap_content。
- UNSPECIFIED :表示childView大小不受限制,不常用。
ViewGroup在onMeasure測量過程中,會遍歷子元素的measure方法並獲得子元素自我測量值(呼叫onMeasure),然後根據子元素的測量值計算出自身測量值。即ViewGroup呼叫setMeasuredDimension()方法測量自身寬高。若widthMode是EXACTLY,則採用widthSize,若heightMode是AT_MOST,則需根據佈局方式(橫向/縱向)計算出子元素的測量值。
補充知識點:
- 在measureChild方法中,父容器獲取子元素的LayoutParams,通過getChildMeasureSpec獲得子元素的MeasureSpec。
- View的測量大小是在measure階段確定的,View的最終大小是在layout階段確定的。一般情況下(除主動設定layout頂點位置外),View的測量大小和最終大小是相等的。
- 在Activity/View#onWindowFocusChanged,View#post(runnable)方法中獲取View的寬高是正確的時機。
onLayout
在View的layout方法中,通過setFrame設定四個頂點的位置。layout方法中會呼叫onLayout方法用於確定子元素的位置。具體呼叫稍後請看原始碼示例。
onDraw
在View的draw方法中,通過dispatchDraw遍歷子元素的draw方法。
view的繪製由幾步組成:
- 繪製背景 background.draw(canvas)
- 繪製自己(onDraw)
- 繪製children(dispatchDraw)
- 繪製裝飾(onDrawScrollBars)
Canvas和Paint
Android中的圖形繪製就是在一個view指定的畫布Canvas上,繪製一些圖片、形狀或文字等。相關的類有Canvas(畫布)、Paint(畫筆)、RetcP(矩形)等。
- Canvas(畫布) :在被操作的物件(如Bitmap、View)上充當畫板,支援繪製形狀、點陣圖、文字、圖片等。
- Paint(畫筆) :負責繪製的風格,支援設定顏色、透明度、粗細、抗鋸齒、填充效果、文字風格等。
案例1:繪製一個圓餅,標註圓心和文字。
//1,activity的onCreate方法載入自定義view setContentView(new CustomerView(this)); //2,繼承View,重寫onDraw獲取畫布 class CustomerView extends View { //3,宣告圓餅、圓心、文字畫筆 Paint paint1, paint2, paint3; public CustomerView(Context context) { super(context); paint1 = new Paint();//圓餅畫筆 paint1.setAntiAlias(true);//抗鋸齒 paint1.setStrokeWidth(2);//畫筆寬度 paint1.setColor(Color.RED);//畫筆顏色 paint2 = new Paint();//圓心畫筆 paint2.setAntiAlias(true); paint2.setColor(Color.YELLOW); paint3 = new Paint();//文字畫筆 paint3.setAntiAlias(true); paint3.setTextSize(30);//文字大小 paint3.setColor(Color.WHITE); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //繪製圓餅 canvas.drawCircle(300, 300, 200, paint1); //繪製圓心 canvas.drawCircle(300, 300, 10, paint2); //繪製文字 canvas.drawText("圓心", 320, 310, paint3); } }
案例中主要運用了Paint的抗鋸齒、畫筆顏色、畫筆寬度、文字大小等屬性。抗鋸齒可使圖形邊緣模糊繪製體現平滑,減少鋸齒效果。
Canvas的獲取 推薦 採用重寫onDraw方法獲取該view指定的畫布。案例中運用了繪製圓餅、文字的API。
案例解析
自定義view的注意事項
- 讓你的view支援wrap_content;
- 如果有必要,讓你的view支援padding,margin;
- 推薦使用post重新整理頁面,Handler側重非同步訊息傳遞;
- 及時停止執行緒或動畫等資源,避免記憶體洩漏,時機參考View#onAttachedToWindow,View#onDetachedFromWindow;
- View中帶有滑動巢狀情形,要處理好滑動衝突,推薦採用外部攔截法。
自定義View的幾種形式
- 繼承View並重寫onDraw方法;如案例1。
- 繼承ViewGroup,重寫onMeasure,onLayout方法;
- 繼承現有ViewGroup,如LinearLayout,FrameLayout,在現有功能上擴充套件功能;
在一般的UI互動需求中,繼承現有的ViewGroup即可實現效果,也降低了繪製成本。
案例2:支援橫向滑動的View,要求有回彈效果。
需求分析:
- 橫向滑動的實現,用LinearLayout即可,其已經內建了橫向子元素的衡量和位置計算。
- 回彈效果的實現,推薦用OverScroller實現。此處用Scroller實現偏移回彈效果,需計算好回彈的觸發時機。
- 滑動衝突?即橫向滑動的ViewGroup和內部子元素對事件分別攔截處理。
關於Scroller動畫的實現,可檢視View屬性知識手札
onTouchEvent()處理的任務:
- 繫結速度追蹤器,根據速度方向和偏移量確定臨近item索引;
- ACTION_MOVE滑動過程中,scrollBy()執行偏移動畫,同步校準臨近item索引;
- ACTION_UP滑動抬起時,根據索引和scrollX()計算出微調至臨近item的偏移量。慣性滑動可參考Scroller.fling();
onInterceptTouchEvent()外部攔截法處理滑動衝突。當橫向滑動距離大於縱向滑動距離時,橫向ViewGroup攔截事件,記錄螢幕操作的座標並執行橫向滑動操作。
完整原始碼如下:
public class MyView extends LinearLayout { Scroller scroller; int childWidth; VelocityTracker velocityTracker; int lastTouchX; int nearlyChildIndex;//偏移對應的最近item索引 int lastInterceptX, lastInterceptY; int touchSlop; public MyView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(context, attrs, 0); } private void init(Context context, AttributeSet attrs, int defStyleAttr) { scroller = new Scroller(context); velocityTracker = VelocityTracker.obtain(); touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); if (getChildCount() > 0) { childWidth = getChildAt(0).getMeasuredWidth(); } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { boolean isIntercepted = false; int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: isIntercepted = false; if (!scroller.isFinished()) { scroller.abortAnimation(); isIntercepted = true; } break; case MotionEvent.ACTION_MOVE: int offsetX = x - lastInterceptX; int offsetY = y - lastInterceptY; //橫向滑動大於縱向滑動時 攔截事件 if (Math.abs(offsetX) > Math.abs(offsetY) && Math.abs(offsetX) > touchSlop) { isIntercepted = true; //記錄事件攔截時座標 lastTouchX = x; } else { isIntercepted = false; } break; case MotionEvent.ACTION_UP: isIntercepted = false; break; } lastInterceptX = x; lastInterceptY = y; return isIntercepted; } @Override public boolean onTouchEvent(MotionEvent event) { velocityTracker.addMovement(event); int touchX = (int) event.getX(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //ViewGroup的ACTION_DOWN事件預設不攔截,不在此捕獲事件座標, // 正確獲取時機在ViewGroup開始攔截事件時。 if (!scroller.isFinished()) { scroller.abortAnimation(); } break; case MotionEvent.ACTION_MOVE: int offsetX = touchX - lastTouchX; scrollBy(-offsetX, 0);//滑動時偏移 //滑動時同步校準臨近child索引 nearlyChildIndex = getScrollX() / childWidth; break; case MotionEvent.ACTION_UP: velocityTracker.computeCurrentVelocity(1000); int velocityX = (int) velocityTracker.getXVelocity(); //左負右正 //粗調:滑動抬起時,找到最近的item的索引 if (Math.abs(velocityX) >= childWidth / 2) { nearlyChildIndex = velocityX > 0 ? nearlyChildIndex - 1 : nearlyChildIndex + 1; } else { //計算出累計偏移量折算成item寬度個數(餘數部分超過半個item寬度則+1,未超過為0) nearlyChildIndex = (getScrollX() + childWidth / 2) / childWidth; } //微優化nearliestchildIndex取值 nearlyChildIndex = Math.max(0, Math.min(nearlyChildIndex, getChildCount() - 1)); //微調:滑動抬起時,偏移策略——1.臨近item置左;2.最右item置右 int scrollX; //當最右邊的item完全可見時,最左邊的item索引 int resultIndex = getChildCount() - 1 - ScreenUtil.getScreenWidth() / childWidth; if (nearlyChildIndex >= resultIndex) {//左滑過頭時 // 左滑過頭時,確保最右邊的item可見,強制為偏移在最左邊的item索引 nearlyChildIndex = resultIndex; //左邊最近item置左後,確保最右邊的item置右,需再偏移的量 int result = childWidth - ScreenUtil.getScreenWidth() % childWidth; scrollX = nearlyChildIndex * childWidth - getScrollX() + result; } else { //微調到最近item,並置左 scrollX = nearlyChildIndex * childWidth - getScrollX(); } //偏移微調,左正右負 smoothScrollBy(scrollX, 0); break; } velocityTracker.clear(); lastTouchX = touchX; return super.onTouchEvent(event); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); velocityTracker.recycle(); } public void smoothScrollBy(int x, int y) { scroller.startScroll(getScrollX(), getScrollY(), x, y, 500); invalidate(); } @Override public void computeScroll() { super.computeScroll(); if (scroller.computeScrollOffset()) { scrollTo(scroller.getCurrX(), scroller.getCurrY()); postInvalidate(); } } }
這是針對LinearLayout擴充套件的自定義view,以實現橫向滑動效果,支援滑動半個childview的微調,支援邊界回彈。
也可以針對ViewGroup實現自定義view,這裡就需要重寫onMeasure和onLayout方法,並分別對子元素測量寬高和位置。
案例3:對案例2中需求,以繼承ViewGroup類實現自定義view。
自定義ViewGroup和繼承特殊ViewGroup的區別就是需要自己重寫onMeasure和onLayout,ViewGroup完成對子元素的衡量和定位。
在onMeasure方法中:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int parentWidth = 0, parentHeight = 0; int childrenNum = getChildCount(); for (int i = 0; i < childrenNum; i++) { final View child = getChildAt(i); if (child == null || child.getVisibility() == GONE) continue; //測量子元素(含子元素內外間距) measureChildWithMargins(child, widthMeasureSpec, parentWidth, heightMeasureSpec, 0); //根據子元素計算出父容器寬高期望值 parentWidth += child.getMeasuredWidth(); parentHeight = Math.max(child.getMeasuredHeight(), parentHeight); } //resloveSize()是api內部對不同測量模式下的測量值的獲取方式優化並封裝 setMeasuredDimension(resolveSize(parentWidth, widthMeasureSpec), resolveSize(parentHeight, heightMeasureSpec)); }
setMeasuredDimension(width,height)設定ViewGroup自身的寬高,若ViewGroup的測量模式非MeasureSpec.EXACTLY,則需要遍歷並確認子元素寬高值後,才能確定ViewGroup自身的寬高。
onMeasure要支援無子view狀態下的衡量,個人推薦如上例直接執行 setMeasuredDimension(resolveSize(0, widthMeasureSpec),resolveSize(0, heightMeasureSpec));
-
resolveSize(parentWidth, widthMeasureSpec)是ViewGroup類內部封裝的針對不同測量模式下的測量值。
resolveSize最後呼叫resolveSizeAndState方法,原始碼如下:
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) { final int specMode = MeasureSpec.getMode(measureSpec); final int specSize = MeasureSpec.getSize(measureSpec); final int result; switch (specMode) { case MeasureSpec.AT_MOST: if (specSize < size) { result = specSize | MEASURED_STATE_TOO_SMALL; } else { result = size; } break; case MeasureSpec.EXACTLY: result = specSize; break; case MeasureSpec.UNSPECIFIED: default: result = size; } return result | (childMeasuredState & MEASURED_STATE_MASK); }
即在MeasureSpec.AT_MOST測量模式下,若期望值size小於該模式下指定值specSize,則採用size;在MeasureSpec.EXACTLY模式下,直接用指定值specSize。
- 上面根據測量模式的測量ViewGroup的寬高,也可如下獲得:
int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); ... //如果是AT_MOST模式,設定成我們計算的值;如果是EXACTLY模式,設定成父容器指定的值。 setMeasuredDimension((widthMode == MeasureSpec.EXACTLY) ? widthSize : parentWidth, (heightMode == MeasureSpec.EXACTLY) ? heightSize : parentHeight);
-
measureChildWithMargins()方法負責測量子元素寬高(含內外間距),運用這個方法需要配置ViewGroup的LayoutParams, 否則會報型別轉換異常的問題 。
由於該ViewGroup初始化時預設呼叫含AttributeSet的構造方法,所以推薦採用帶AttributeSet的generateLayoutParams方法。
@Override public LayoutParams generateLayoutParams(AttributeSet attrs) { return new MarginLayoutParams(getContext(), attrs); }
此時無需呼叫
@Override protected boolean checkLayoutParams(LayoutParams p) { return p instanceof MarginLayoutParams; }
上面是配置ViewGroup自帶的LayoutParams,當然也可以自定義LayoutParams屬性。
在onLayout方法中:
@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { int childrenNum = getChildCount(); int resultLeft = getPaddingLeft(); for (int j = 0; j < childrenNum; j++) { if (j == 0) { childWidth = getChildAt(0).getMeasuredWidth(); } final View child = getChildAt(j); if (child == null || child.getVisibility() == GONE) continue; MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams(); //橫向列表 left為累加item寬度和左右間距 left = resultLeft + params.leftMargin; top = params.topMargin + getPaddingTop(); right = left + child.getMeasuredWidth(); bottom = top + child.getMeasuredHeight(); child.layout(left, top, right, bottom); resultLeft += params.leftMargin + child.getMeasuredWidth() + params.rightMargin; } }
這裡主要是通過獲取ViewGroup的padding屬性和子元素的margin屬性,遍歷子元素並逐個定位。
resultLeft = resultLeft + 父容器paddingleft+上一個view的左右margin+上一個view的寬度。
view自身的padding在onDraw方法中計算並體現在canvas畫布繪製上。
XML佈局:
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <com.zjrb.sjzsw.widget.MyView android:layout_width="match_parent" android:layout_height="@dimen/dp_200" android:paddingLeft="@dimen/dp_10"> <Button android:layout_width="@dimen/dp_150" android:layout_height="match_parent" android:layout_marginLeft="@dimen/dp_10" android:layout_marginRight="@dimen/dp_5" android:background="@color/color_7AD859" android:text="1" /> <Button android:id="@+id/button2" android:layout_width="@dimen/dp_150" android:layout_height="match_parent" android:layout_marginTop="@dimen/dp_10" android:background="@color/color_FF6028" android:text="2" /> <Button android:layout_width="@dimen/dp_150" android:layout_height="match_parent" android:background="@color/color_DFBC99" android:text="3" /> <Button android:layout_width="@dimen/dp_150" android:layout_height="match_parent" android:background="@color/color_8666F9" android:text="4" /> <Button android:layout_width="@dimen/dp_150" android:layout_height="match_parent" android:background="@color/color_37b6ff" android:text="5" /> </com.zjrb.sjzsw.widget.MyView> </layout>
最終效果(注意xml中對padding和margin的設定):
