Android 自定義View之Draw過程(上)

前言

Draw 過程系列文章

Android 展示之三部曲:

Measure(測量)---->Layout(擺放)---->Draw(繪制)

前邊我們已經(jīng)分析了:

這倆最主要的任務(wù)是:確定View/ViewGroup可繪制的矩形區(qū)域。
接下來將會(huì)分析,如何在這給定的區(qū)域內(nèi)繪制想要的圖形。

通過本篇文章,你將了解到:

1、為什么要自定義View
2、一個(gè)簡單的Demo
3、View Draw過程
4、ViewGroup Draw過程
5、View/ViewGroup 常用方法分析

為什么要自定義View

Android 提供了關(guān)于View最基礎(chǔ)的兩個(gè)類:

ViewGroup與View

然而ViewGroup 并沒有約定其內(nèi)部的子View是如何布局的,是疊加在一起呢?還是橫向擺放、縱向擺放等。同樣的View 也沒有約定其展示的內(nèi)容是啥樣,是矩形、圓形、三角形、一張圖片、一段文字抑或是不規(guī)則的形狀?這些都要我們自己去實(shí)現(xiàn)嗎?
不盡然,值得高興的是Android已經(jīng)考慮到上述需求了,為了開發(fā)方便已經(jīng)預(yù)制了一些常用的ViewGroup、View。
如:
繼承自ViewGroup的子類

FrameLayout --> 里面的子View是層疊擺放的
LinearLayout -->里邊的子View是可以縱向/橫向排列的
RelativeLayout -->里邊的子View可以相對布局
RecyclerView -->里邊的子View以列表形式展示
等等...

繼承自View的子類

TextView --> 用于繪制一段文本
ImageView --> 用于繪制一張圖片
EditText -->用于繪制輸入框
Button --> 用戶繪制按鈕
等等...

雖然以上衍生的View/ViewGroup子類已經(jīng)大大為我們提供了便利,但也僅僅是通用場景下的通用控件,我們想實(shí)現(xiàn)一些較為復(fù)雜的效果,比如波浪形狀進(jìn)度條、會(huì)發(fā)光的球體等,這些系統(tǒng)控件就無能為力了,也沒必要去預(yù)制千奇百怪的控件。想要達(dá)到此效果,我們需要自定義View/ViewGroup。
通常來說自定義View/ViewGroup有以下幾種:

1、如果你覺得系統(tǒng)提供的ViewGroup子類基本符合你需求,但你想將一些功能封裝到一個(gè)組件里,那么就直接繼承FrameLayout、LinearLayout等。這樣一來,繼承了他們的特性,也將自己的邏輯封裝了。
2、如果你覺得系統(tǒng)提供的View子類基本符合你的需求,但你想將一些功能封裝到一個(gè)控件里,比如顯示Emoji,那么直接繼承自TextView(AppCompatTextView兼容)。
3、如果你看不起系統(tǒng)預(yù)制的ViewGroup子類,直接繼承自ViewGroup,那么你需要重寫onMeasure(xx)、onLayout(xx)等方法。
4、如果不想用系統(tǒng)預(yù)制的View子類,直接繼承自View,那么你需要自己繪制內(nèi)容,重寫onDraw(xx)方法。

3 一般不怎么用,除非布局比較特殊。1、2、4 是我們常用的手段,對于我們常說的"自定義View" 一般指的是 4。
接下來我們來看看 4是怎么實(shí)現(xiàn)的。

一個(gè)簡單的Demo

public class MyView extends View {

    private Paint paint;

    public MyView(Context context) {
        super(context);
        init();
    }

    //從xml加載MyView時(shí)調(diào)用該方法
    public MyView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init() {
        paint = new Paint();
        paint.setAntiAlias(true);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        //涂紅色
        canvas.drawColor(Color.RED);

        //畫筆設(shè)置為黃色
        paint.setColor(Color.YELLOW);
        //畫實(shí)心圓
        canvas.drawCircle(getWidth()/2, getHeight()/2, 30, paint);
    }

在xml里引用MyView

    <com.fish.myapplication.MyView
        android:id="@+id/myview"
        android:layout_width="100px"
        android:layout_height="100px">
    </com.fish.myapplication.MyView>

效果如下:


image.png

黑色部分為其父布局背景。
紅色矩形+黃色圓形即是MyView繪制的內(nèi)容。
以上是最簡單的自定義View的實(shí)現(xiàn),我們提取重點(diǎn)歸納如下:

1、繼承自View
2、重寫onDraw(xx)方法(通常onMeasure(xx)也需要重寫,此處為突出重點(diǎn)忽略)

View Draw過程

View onDraw(xx)

由上述Demo可知,我們只需要在重寫的onDraw(xx)方法里繪制想要的圖形即可。
來看看View 默認(rèn)的onDraw(xx)方法:

#View.java
    protected void onDraw(Canvas canvas) {
    }

發(fā)現(xiàn)是個(gè)空實(shí)現(xiàn),因此繼承自View的類必須重寫onDraw(xx)方法才能實(shí)現(xiàn)繪制。該方法傳入?yún)?shù)為:Canvas類型。
Canvas翻譯過來一般叫做畫布,在重寫的onDraw(xx)里拿到Canvas對象后,有了畫布我們還需要一支筆,這只筆即為Paint,翻譯過來一般稱作畫筆。兩者結(jié)合,就可以愉快的作畫(繪制)了。
你可能發(fā)現(xiàn)了,在Demo里調(diào)用

canvas.drawColor(Color.RED);

并沒有傳入Paint啊,是不是Paint不是必須的?實(shí)際上調(diào)用該方法后,底層會(huì)自動(dòng)生成Paint對象。

#SkCanvas.cpp
void SkCanvas::drawColor(SkColor c, SkBlendMode mode) {
    SkPaint paint;
    paint.setColor(c);
    paint.setBlendMode(mode);
    this->drawPaint(paint);
}

可以看到,底層初始化了Paint,并且給其設(shè)置的顏色為在Java層設(shè)置的顏色。

View Draw(xx)

onDraw(xx)比較簡單,開局一個(gè)Canvas,效果全靠畫。
試想,這個(gè)Canvas怎么來的呢,換句話說是誰調(diào)用了onDraw(xx)。發(fā)揮一下聯(lián)想功能,在Measure、Layout 過程有提到過兩者套路很像:

measure(xx)、layout(xx) 一般不需要重寫
onMeasure(xx)、onLayout(xx)[View 不需要] 需要重寫
measure(xx)里調(diào)用了onMeasure(xx)
layout(xx)里調(diào)用了onLayout(xx)

那么Draw過程是否也是如此套路呢?看見了onDraw(xx),那么draw(xx)還遠(yuǎn)嗎?
沒錯(cuò),還真有draw(xx)方法:

#View.java
    public void draw(Canvas canvas) {
        final int privateFlags = mPrivateFlags;
        //打上已繪制標(biāo)記
        mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;
        int saveCount;
        //第一步 繪制背景
        drawBackground(canvas);

        final int viewFlags = mViewFlags;
        //檢查橫向、縱向是否設(shè)置了邊緣漸變效果
        boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
        boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
        
        //條件分支A
        if (!verticalEdges && !horizontalEdges) {
            //第三步 調(diào)用onDraw(xx),繪制View 內(nèi)容
            onDraw(canvas);(1)

            //第四步 分發(fā)Draw,繪制子布局
            dispatchDraw(canvas); (2)
            //繪制自動(dòng)填充的高亮(默認(rèn)不會(huì)繪制)
            drawAutofilledHighlight(canvas);

            //mOverlay 繪制在內(nèi)容之上,在前景色之下 (3)
            if (mOverlay != null && !mOverlay.isEmpty()) {
                mOverlay.getOverlayView().dispatchDraw(canvas);
            }

            //第六步,繪制裝飾,如前景、滾動(dòng)條等 (4)
            onDrawForeground(canvas);

            //第七步,繪制默認(rèn)高亮,在touch mode模式基本不生效
            drawDefaultFocusHighlight(canvas);
            //調(diào)試用的,可以忽略
            if (debugDraw()) {
                debugDrawFocus(canvas);
            }

            //繪制完成,直接返回
            return;
        }

        //條件分支B
        //下面還有一大堆源碼,主要就是做了一件事:繪制邊緣漸變
        //大部分情況下都不會(huì)走到這
        //繪制步驟大體分為 七 個(gè)步驟,而上面只列出了1、3、4、6、7,剩下的步驟在此完成
        //如果設(shè)置了邊緣漸變,那么繪制步驟就會(huì)比不設(shè)置時(shí)多兩個(gè)步驟,多出來的步驟是:2、5
        //用文字簡單概述一下
        //1--->繪制背景
        //2--->canvas.getSaveCount(); 記錄canvas狀態(tài),為繪制邊緣漸變做準(zhǔn)備(canvas坐標(biāo)要改變,因此先保存)
        //3--->繪制內(nèi)容
        //4--->分發(fā)Draw,繪制子布局
        //5--->繪制邊緣漸變
        //6--->繪制裝飾
        //7--->繪制默認(rèn)高亮
    }

可以看出,draw(xx)主要分為兩個(gè)部分:

  • 條件分支A-->大部分情況下都會(huì)走該分支
  • 條件分支B--->極小部分情況會(huì)走該分支
  • B分支比A分支多了個(gè)2個(gè)步驟,目的是為了繪制邊緣漸變效果

不管是A分支還是B分支,都進(jìn)行了好幾步的繪制。
通常來說,單一一個(gè)View的層次分為:

背景-->內(nèi)容-->前景

后面繪制的可能會(huì)遮擋前邊繪制的。
對于一個(gè)ViewGroup來說,層次分為:

背景-->內(nèi)容-->子布局的層次-->前景

來看看A分支標(biāo)注的4個(gè)點(diǎn):
(1)
onDraw(canvas)
前面分析過,對于單一的View,onDraw(xx)是空實(shí)現(xiàn),需要由我們自定義繪制。
而對于ViewGroup,也并沒有具體實(shí)現(xiàn),如果在自定義ViewGroup里重寫onDraw(xx),它會(huì)執(zhí)行嗎?默認(rèn)是不會(huì)執(zhí)行的,相關(guān)分析請移步:
Android ViewGroup onDraw為什么沒調(diào)用

(2)
dispatchDraw(canvas),來看看在View.java里的實(shí)現(xiàn):

    protected void dispatchDraw(Canvas canvas) {

    }

發(fā)現(xiàn)是個(gè)空實(shí)現(xiàn),再看看ViewGroup.java里的實(shí)現(xiàn):

    protected void dispatchDraw(Canvas canvas) {
        ...
        //遍歷子布局,發(fā)起Draw 過程
        ...
    }

也即是說,對于單一View,因?yàn)闆]有子布局,因此沒必要再分發(fā)Draw,而對于ViewGroup來說,需要觸發(fā)其子布局發(fā)起Draw過程(此過程后續(xù)分析),可以類比事件分發(fā)過程View、ViewGroup的處理。感興趣的請移步:
Android 輸入事件一擼到底之View接盤俠(3)

(3)
OverLay,顧名思義就是"蓋在某個(gè)東西上面",此處是在繪制內(nèi)容之后,繪制前景之前。怎么用呢?

        View viewGroup = findViewById(R.id.myviewgroup);
        //給overLay 指定一個(gè)Drawable
        Drawable drawable = ContextCompat.getDrawable(this, R.drawable.shapeme);
        //設(shè)置Drawable 的尺寸
        drawable.setBounds(0, 0, 400, 58);
        //為overLay添加Drawable
        viewGroup.getOverlay().add(drawable);

以上是給一個(gè)ViewGroup設(shè)置overLay,效果如下:

image.png

黑色部分為ViewGroup背景
紅色矩形+黃色圓圈 為子布局
黃色矩形即為為ViewGroup添加的overLay,可以看出overLay繪制在內(nèi)容之上。
(4)
onDrawForeground(xx)
繪制前景,使用方法如下:

        View viewGroup = findViewById(R.id.myviewgroup);
        Drawable drawable = ContextCompat.getDrawable(this, R.drawable.shapeme);
        drawable.setBounds(0, 0, 400, 58);
        viewGroup.setForeground(drawable);

你可能發(fā)現(xiàn)了,這和設(shè)置overLay差不多的嘛,實(shí)際還是有差別的。在onDrawForeground(xx)里會(huì)重新調(diào)整Drawable的尺寸,該尺寸與View大小一致,之前給Drawable設(shè)置的尺寸會(huì)失效。運(yùn)行效果如下:


image.png

可以看出,ViewGroup都被前景蓋住了。
再來看看B分支的重點(diǎn):邊緣漸變效果
先來看看TextView 邊緣漸變效果:

my (1).gif

這是個(gè)TextView,以跑馬燈的形式展示。
給它水平方向加上邊緣漸變效果,如上所示,兩邊是漸變的。
怎么實(shí)現(xiàn)的呢?

    //水平還是垂直方向
    android:requiresFadingEdge="horizontal"
    //漸變的長度
    android:fadingEdgeLength="100dp"

加上這倆參數(shù)。
實(shí)際上系統(tǒng)自帶的一些控件也使用了該效果,如NumberPicker、YearPickerView


you.gif

以上是NumberPicker 的效果,可以看出是垂直方向漸變的。

ViewGroup Draw過程

對于View.java 里的onDraw(xx)、draw(xx),ViewGroup.java里并沒有重寫。
而對于dispatchDraw(xx),在View.java里是空實(shí)現(xiàn)。在ViewGroup.java里發(fā)起對子布局的繪制。

ViewGroup dispatchDraw(xx)

#ViewGroup.java
    @Override
    protected void dispatchDraw(Canvas canvas) {
        boolean usingRenderNodeProperties = canvas.isRecordingFor(mRenderNode);
        final int childrenCount = mChildrenCount;
        final View[] children = mChildren;
        int flags = mGroupFlags;
        //動(dòng)畫相關(guān)
        ...
        int clipSaveCount = 0;
        //設(shè)置了padding后,繪制的子布局不能超過padding (1)
        final boolean clipToPadding = (flags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK;
        if (clipToPadding) {
            //因此需要對canvas坐標(biāo)進(jìn)行變換,先保存其狀態(tài)
            clipSaveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
            canvas.clipRect(mScrollX + mPaddingLeft, mScrollY + mPaddingTop,
                    mScrollX + mRight - mLeft - mPaddingRight,
                    mScrollY + mBottom - mTop - mPaddingBottom);
        }

        //重置相關(guān)標(biāo)記
        mPrivateFlags &= ~PFLAG_DRAW_ANIMATION;
        mGroupFlags &= ~FLAG_INVALIDATE_REQUIRED;
        ...
        for (int i = 0; i < childrenCount; i++) {
            ...
            //遍歷子布局,發(fā)起子布局繪制
            final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
            final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
            if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
                more |= drawChild(canvas, child, drawingTime); (2)
            }
        }
        ...
    }

來看看標(biāo)記的2點(diǎn):
(1)
設(shè)置padding的目的是為了讓子布局留出一定的空隙出來,因此當(dāng)設(shè)置了padding后,子布局的canvas需要根據(jù)padding進(jìn)行裁減。判斷標(biāo)記為:

(flags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK

protected static final int CLIP_TO_PADDING_MASK = FLAG_CLIP_TO_PADDING | FLAG_PADDING_NOT_NULL;

FLAG_CLIP_TO_PADDING 默認(rèn)設(shè)置為true
FLAG_PADDING_NOT_NULL 只要有padding不為0,該標(biāo)記就會(huì)打上。
也就是說:只要設(shè)置了padding 不為0,子布局顯示區(qū)域需要裁減。
能不能不讓子布局裁減顯示區(qū)域呢?
答案是可以的。
考慮到一種場景:使用RecyclerView的時(shí)候,我們需要設(shè)置paddingTop = 20px,效果是:RecyclerView Item展示時(shí)離頂部有20px,但是滾動(dòng)的時(shí)候永遠(yuǎn)滾不到頂部,看起來不是那么友好。這就是上述的裁減起作用了,需要將此動(dòng)作禁止。通過設(shè)置:

setClipToPadding(false)

當(dāng)然也可以在xml里設(shè)置:

android:clipToPadding="false"

(2)
drawChild(xx)

#ViewGroup.java
    protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
        return child.draw(canvas, this, drawingTime);
    }

從方法名上看是調(diào)用子布局進(jìn)行繪制。
child.draw(x1,x2,x3)里分兩種情況:

一是 硬件加速繪制
二是 軟件繪制

這兩者具體作用與區(qū)別會(huì)在下篇文章分析,不管是硬件加速繪制還是軟件加速繪制,最終都會(huì)調(diào)用View.draw(xx)方法,該方法上面已經(jīng)分析過。
注意,draw(x1,x2,x3)與draw(xx)并不一樣,不要搞混了。

View/ViewGroup 常用方法分析

用圖表示:


image.png

View/ViewGroup Draw過程的聯(lián)系:


image.png

一般來說,我們通常會(huì)自定義View,并且重寫其onDraw(xx)方法,有沒有繪制內(nèi)容的ViewGroup需求呢?
是有的,舉個(gè)例子,大家可以去看看RecyclerView ItemDecoration 的繪制,其中運(yùn)用到了ViewGroup draw(xx)、ViewGroup onDraw(xx) 、View onDraw(xx)繪制的先后順序來實(shí)現(xiàn)分割線,分組頭部懸停等功能的。

本篇文章基于 Android 10.0

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容