如何寫出自定義View——Google 轉盤

前言

今天看了Google上關於View的教程,看的真是雲裏霧裏。雖然篇幅很長,看完一遍實在不想再看第二遍,不過自己還是堅持看完了第二遍,第三遍,第四遍。。。後面幾遍越看越熟練,我看着Google示例APP裏面的代碼,然後參考着Google教程裏面的講解,慢慢的和教程裏面的一個個點對上號。到寫這篇博客爲止,我還是沒有很好的把其中的原理融會貫通,但是還是想先記錄一下目前從代碼裏學習到的知識,以後看到好的View代碼再多練習。

比如自定義XML屬性我都不是很熟悉,在這裏只是熟悉它的大概流程,裏真正學會還差很遠。

正文

首先我們來看看效果圖(動態圖不會弄。。見諒):
這裏寫圖片描述

這裏寫圖片描述

我們可以用手指拖動這個圓盤開始旋轉,然後過一段時間它會慢慢停下來,並且旋轉過程中左邊對應的餅圖部分的文字一直在變化。

下面我就按照如何自己寫出一個自定義View的過程來記錄學到的東西:

定義一個View子類

自定義當然離不開自己創建一個類繼承View,不過我們可以選擇繼承基礎類view,也可以是基礎類之上的類(如Button),如果UI複雜想在自定義View裏繼續嵌套子元素,還可以繼承ViewGroup。比如在PieChart裏面,是這麼寫的:

public class PieChart extends ViewGroup {

定義自定義樣式

每一個View都有自己的樣式,比如說我們在XML裏面經常使用的 android:layout_width = “match_parent”,如果我們自己定製View,我們還可以自己定義自己的樣式,習慣上來講,我們把自定義view的自定義屬性放在res/values/attrs.xml文件裏面。我們需要在< resource >元素下面定義一個子元素 < declare-styleable >,然後寫上這個樣式的名字,一般和自定義View類的名字一樣。在這個< declare-styleable >裏面來定義有需求的屬性,在pieChart裏面是這樣的:

    <declare-styleable name="PieChart">
        <attr name="autoCenterPointerInSlice" format="boolean"/>
        <attr name="highlightStrength" format="float"/>
        <attr name="labelColor" format="color"/>
        <attr name="labelHeight" format="dimension"/>
        <attr name="labelPosition" format="enum">
            <enum name="left" value="0"/>
            <enum name="right" value="1"/>
        </attr>
        <attr name="labelWidth" format="dimension"/>
        <attr name="labelY" format="dimension"/>
        <attr name="pieRotation" format="integer"/>
        <attr name="pointerRadius" format="dimension"/>
        <attr name="showText" format="boolean"/>
    </declare-styleable>

然後我們應該在XML文件裏面應用這個樣式,不過我們應該重新加入一個新的命名空間,比如之前我們用android:layout_weight,android:centerInParent之類的屬性,其實都是因爲先引入了xmlns:android=”http://schemas.android.com/apk/res/android”,”http://schemas.android.com/apk/res/android“是具體的URI,用指令xmlns可以指定一個別名還代替這一長串的URI,我們這裏引入xmlns:custom=”http://schemas.android.com/apk/res/com.example.custom_view”,這樣,我們就能用custom:XX=YY這樣的自定義屬性了。

注意:引入自己的命名空間,規則是在res後面加入APP的具體包名。這裏的包名是com.example.custom_view。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:custom="http://schemas.android.com/apk/res/com.example.custom_view"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.example.custom_view.PieChart
        android:id="@+id/Pie"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="10dp"
        android:layout_weight="100"
        custom:showText="true"
        custom:labelHeight="20dp"
        custom:labelWidth="110dp"
        custom:labelY="85dp"
        custom:labelPosition="left"
        custom:highlightStrength="1.12"
        android:background="@android:color/white"
        custom:pieRotation="0"
        custom:labelColor="@android:color/black"
        custom:autoCenterPointerInSlice="true"
        custom:pointerRadius="4dp"
        />
    <Button
        android:id="@+id/Reset"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/reset_button"
        />
</LinearLayout>

應用自定義樣式

應用自定義樣式要在自定義View的構造函數裏面取出來,然後應用給View裏面的成員變量,如果是代碼裏面直接新建View,一般都調用的是:

/**
     * Class constructor taking only a context. Use this constructor to create
     * {@link PieChart} objects from your own code.
     *
     * @param context
     */
    public PieChart(Context context) {
        super(context);
        init();
    }

只要傳遞一個上下文context進入就行了。

如果我們是在XML文件裏面指定好了,然後我們就要調用另一構造函數,因爲如果使用上一個構造函數的話,它單單是建立了一個含有默認樣式的View,而我們在XML文件裏面定製好的屬性樣式都完全沒有應用給它。這個時候我們應該調用這個構造函數:

/**
     * Class constructor taking a context and an attribute set. This constructor
     * is used by the layout engine to construct a {@link PieChart} from a set of
     * XML attributes.
     *
     * @param context
     * @param attrs   An attribute set which can contain attributes from
     *                {@link com.example.android.customviews.R.styleable.PieChart} as well as attributes inherited
     *                from {@link android.view.View}.
     */
    public PieChart(Context context, AttributeSet attrs) {
        super(context, attrs);

        // attrs contains the raw values for the XML attributes
        // that were specified in the layout, which don't include
        // attributes set by styles or themes, and which may have
        // unresolved references. Call obtainStyledAttributes()
        // to get the final values for each attribute.
        //
        // This call uses R.styleable.PieChart, which is an array of
        // the custom attributes that were declared in attrs.xml.
        TypedArray a = context.getTheme().obtainStyledAttributes(
                attrs,
                R.styleable.PieChart,
                0, 0
        );

            try {
                // Retrieve the values from the TypedArray and store into
                // fields of this class.
                //
                // The R.styleable.PieChart_* constants represent the index for
                // each custom attribute in the R.styleable.PieChart array.
                mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
                mTextY = a.getDimension(R.styleable.PieChart_labelY, 0.0f);
                mTextWidth = a.getDimension(R.styleable.PieChart_labelWidth, 0.0f);
                mTextHeight = a.getDimension(R.styleable.PieChart_labelHeight, 0.0f);
                mTextPos = a.getInteger(R.styleable.PieChart_labelPosition, 0);
                mTextColor = a.getColor(R.styleable.PieChart_labelColor, 0xff000000);
                mHighlightStrength = a.getFloat(R.styleable.PieChart_highlightStrength, 1.0f);
                mPieRotation = a.getInt(R.styleable.PieChart_pieRotation, 0);
                mPointerRadius = a.getDimension(R.styleable.PieChart_pointerRadius, 2.0f);
                mAutoCenterInSlice = a.getBoolean(R.styleable.PieChart_autoCenterPointerInSlice, false);
            } finally {
            // release the TypedArray so that it can be reused.
            a.recycle();
        }

        init();
    }

我們通過obtainStyledAttrubutes()把屬性讀取到TypeArray裏面,需要傳入構造函數的參數attrs,我們自定義的屬性R.styleable.PieChart,兩個0(不知道什麼用)。然後這樣我們就把XML裏面定製好的屬性讀取到TypedArray數組裏了。

TypedArray a = context.getTheme().obtainStyledAttributes(
                attrs,
                R.styleable.PieChart,
                0, 0
        );

接下來我們就要把需要讀取的屬性從獲取到的TypedArray對象裏獲取出來,就像下面這樣,好像取key_value對一樣,其中R.styleable.PieChart_*這些是系統自動幫我們生成的R.styleable.PieChart對應屬性的索引。如果取到了就是XML文件裏面定製好的屬性,如果沒有取到我們就用默認的屬性。

     mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
     mTextY = a.getDimension(R.styleable.PieChart_labelY, 0.0f);

還有,最後一定要回收TypedArray資源,因爲這是系統的一個共享資源。

a.recycle();

添加屬性和事件

爲了提供動態的行爲,我們應該暴露出那些會影響VIew外觀和行爲的屬性。比如說我們把showText改成false,那麼我們就需要重新計算最好的尺寸和佈局,我們就要調用 invalidate() 和 requestLayout()。忘記這些方法的調用我們就很難發現BUG。

當我們開發的時候,很容易忘記這一點。那我們應該遵循一個好的規則,總是把那些會影響View外觀和行爲的屬性暴露出來,並調用invalidate()或是requestLayout()來讓View變得可靠。

    public float getPointerRadius() {
        return mPointerRadius;
    }

    public void setPointerRadius(float pointerRadius) {
        mPointerRadius = pointerRadius;
        invalidate();
    }

創建繪圖對象

在畫圖之前我們要先準備好畫筆,在PieChart裏面,我們要預先準備好畫餅圖的畫筆,畫字的畫筆,畫陰影的畫筆。而且我們應該在構造函數裏面完成這一操作,因爲放在onDraw()方法裏面會導致每次重繪都要重新初始化畫筆,會讓View變的拖沓。

private void init() {
        // Force the background to software rendering because otherwise the Blur
        // filter won't work.
        setLayerToSW(this);

        // Set up the paint for the label text
        mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setColor(mTextColor);
        if (mTextHeight == 0) {
            mTextHeight = mTextPaint.getTextSize();
        } else {
            mTextPaint.setTextSize(mTextHeight);
        }

        // Set up the paint for the pie slices
        mPiePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPiePaint.setStyle(Paint.Style.FILL);
        mPiePaint.setTextSize(mTextHeight);

        // Set up the paint for the shadow
        mShadowPaint = new Paint(0);
        mShadowPaint.setColor(0xff101010);
        mShadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL));
        ... ...

處理佈局事件

在初始化完畫筆之後,畫圖之前我們還有一項工作,就是確定好佈局大小。一般在View第一次被指定大小的時候,系統會調用onSizeChanged()方法,在這個方法裏面我們要確定好View裏面每一個子元素的大小,確定好每一個子元素和子元素之間的位置關係。

@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        //
        // Set dimensions for text, pie chart, etc
        //
        // Account for padding
        float xpad = (float) (getPaddingLeft() + getPaddingRight());
        float ypad = (float) (getPaddingTop() + getPaddingBottom());

        // Account for the label
        if (mShowText) xpad += mTextWidth;

        float ww = (float) w - xpad;
        float hh = (float) h - ypad;

        // Figure out how big we can make the pie.
        float diameter = Math.min(ww, hh);
        mPieBounds = new RectF(
                0.0f,
                0.0f,
                diameter,
                diameter);
        mPieBounds.offsetTo(getPaddingLeft(), getPaddingTop());

        mPointerY = mTextY - (mTextHeight / 2.0f);
        float pointerOffset = mPieBounds.centerY() - mPointerY;

        // Make adjustments based on text position
        if (mTextPos == TEXTPOS_LEFT) {
            mTextPaint.setTextAlign(Paint.Align.RIGHT);
            if (mShowText) mPieBounds.offset(mTextWidth, 0.0f);
            mTextX = mPieBounds.left;

            if (pointerOffset < 0) {
                pointerOffset = -pointerOffset;
                mCurrentItemAngle = 225;
            } else {
                mCurrentItemAngle = 135;
            }
            mPointerX = mPieBounds.centerX() - pointerOffset;
        } else {
            mTextPaint.setTextAlign(Paint.Align.LEFT);
            mTextX = mPieBounds.right;

            if (pointerOffset < 0) {
                pointerOffset = -pointerOffset;
                mCurrentItemAngle = 315;
            } else {
                mCurrentItemAngle = 45;
            }
            mPointerX = mPieBounds.centerX() + pointerOffset;
        }

如果我們想要更加精細的去控制View的大小,我們要實現onMeasure()方法。覆寫這個方法會有兩個參數,這兩個參數分別是系統想要這個View的寬度和高度。我們在確定這個View的大小的時候,我們應該注意一點,確定這個View的Padding是View的責任,千萬不要忘記。

resolveSizeAndState()方法是通過比較View想要的值和傳入的參數來返回一個 View.MeasureSpec 參數,傳入的參數是兩個int類型的參數。onMeasure()方法沒有返回值,我們是通過setMeasuredDimension()把結果地交給系統,傳入的兩個View.MeasureSpec類型的值,分別是寬和高。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Try for a width based on our minimum
        int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();

        int w = Math.max(minw, MeasureSpec.getSize(widthMeasureSpec));

        // Whatever the width ends up being, ask for a height that would let the pie
        // get as big as it can
        int minh = (w - (int) mTextWidth) + getPaddingBottom() + getPaddingTop();
        int h = Math.min(MeasureSpec.getSize(heightMeasureSpec), minh);

        setMeasuredDimension(w, h);
    }

開始繪圖

終於開始最重要的一步了,這個步驟在onDraw()裏面完成。就是調用系統的一些方法,比如drawText(),drawRect(), drawOval(),drawPath(),drawBitmap()畫出一些基本的圖形,然後用setStyle(),等一些方法來確定樣式。

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

        // Draw the shadow
        canvas.drawOval(mShadowBounds, mShadowPaint);

        // Draw the label text
        if (getShowText()) {
            canvas.drawText(mData.get(mCurrentItem).mLabel, mTextX, mTextY, mTextPaint);
        }

處理輸入手勢

當然,繪畫當然是自定義View裏面最重要的一步,但是能讓View響應用戶的操作也很重要,要不然和圖片有什麼區別。

我們知道用戶的手勢無非是這麼幾種,輕點,推,拉,快速移動,縮放,爲了把這些原始的接觸事件轉化爲手勢,Android提供了 GestureDetector 。我們先要構造一個 GestureDetector ,然後通過這個 GestureDetector 來處理一些手勢,如果處理不了,我們再自己自定義手勢的處理方案。

首先要構造一個 GestureDetector ,我們要讓一個類去繼承 GestureDetector.OnGestureListener ,如果只想處理幾個簡單的事件我們可以繼承GestureDetector.SimpleOnGestureListener,然後在這個類裏面定義好處理各種手勢的代碼。

    private class GestureListener extends GestureDetector.SimpleOnGestureListener {
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            // Set the pie rotation directly.
            float scrollTheta = vectorToScalarScroll(
                    distanceX,
                    distanceY,
                    e2.getX() - mPieBounds.centerX(),
                    e2.getY() - mPieBounds.centerY());
            setPieRotation(getPieRotation() - (int) scrollTheta / FLING_VELOCITY_DOWNSCALE);
            return true;
        }

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            // Set up the Scroller for a fling
            float scrollTheta = vectorToScalarScroll(
                    velocityX,
                    velocityY,
                    e2.getX() - mPieBounds.centerX(),
                    e2.getY() - mPieBounds.centerY());
            mScroller.fling(
                    0,
                    (int) getPieRotation(),
                    0,
                    (int) scrollTheta / FLING_VELOCITY_DOWNSCALE,
                    0,
                    0,
                    Integer.MIN_VALUE,
                    Integer.MAX_VALUE);
                    ......

定義好這個類之後,我們就可以把這個類作爲參數傳遞給GestureDetector:

mDetector = new GestureDetector(PieChart.this.getContext(), new GestureListener());

然後我們在onTouchEvent()方法裏面,先把手勢傳遞給GestureDetector,看它能不能解決,如果它能解決,會返回true,如果不能解決就會返回false,如果返回false,我們還需要自定義處理的代碼。

@Override
    public boolean onTouchEvent(MotionEvent event) {
        // Let the GestureDetector interpret this event
        boolean result = mDetector.onTouchEvent(event);

        // If the GestureDetector doesn't want this event, do some custom processing.
        // This code just tries to detect when the user is done scrolling by looking
        // for ACTION_UP events.
        if (!result) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                // User is done scrolling, it's now safe to do things like autocenter
                stopScrolling();
                result = true;
            }
        }
        return result;
    }

創建直觀正確的運動

我們在創建動畫,不應該只滿足於創建出來動畫,而是讓動畫儘可能的和真實世界裏一樣,這樣用戶纔不會覺得訝異。比如所快速推一個物體,它應該是先慢慢加速,勻速,然後再慢慢停下來。玩轉盤的時候,它也是先快,然後慢慢停下來。

要想自己實現這樣的效果能難,不過Android已經幫我們集成好了一些相關的方法,只要傳入參數,就能幫我們計算好各個運動時刻的參數值。比如在GestureListener裏面的onFling()方法裏,我們將一些參數傳入Scroller類裏的fling()方法,就可以根據當前的速度,位置等信息,自動生成各個時刻的信息。

@Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            // Set up the Scroller for a fling
            mScroller.fling(
                    0,
                    (int) getPieRotation(),
                    0,
                    (int) scrollTheta / FLING_VELOCITY_DOWNSCALE,
                    0,
                    0,
                    Integer.MIN_VALUE,
                    Integer.MAX_VALUE);

當然fling()方法只是爲我們提供了一個值,它並不會把這些時刻變化的位置信息應用到我們的View上,所以我們應該從Scroller裏面得到這些值,然後更新View就好。

要去更新有兩種方式,一種就是調用postInvalidate()去強制調用onDraw()方法去重繪,不過這個方法需要我們在onDraw()方法裏面計算偏移量,然後每次改變的時候重新調用postInvalidate()。

第二種方式是添加一個ValueAnimator去監聽滑動,添加一個監聽器去處理動畫的更新。

        if (Build.VERSION.SDK_INT >= 11) {
            mScrollAnimator = ValueAnimator.ofFloat(0, 1);
            mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                public void onAnimationUpdate(ValueAnimator valueAnimator) {
                    tickScrollAnimation();
                }
            });
        }

第二種方式雖然實現比較複雜(不過我覺得第一種更麻煩),但是它和系統結合的更緊密,而且不會產生多餘的View。它在Android3.0的時候被引入(API11)。如果考慮低版本,我們應該在運行的時候檢查一下版本。

            if (Build.VERSION.SDK_INT >= 11) {
                mScrollAnimator.setDuration(mScroller.getDuration());
                mScrollAnimator.start();
            }
            return true;

讓你的過渡平滑

還有一個小小的知識點,就是在View的位置做任何改變的時候都不要突然性的改變位置信息,而是要通過動畫的形式來改變。因爲在真實世界裏,東西是不會突然出現,消失的,不會突然停下,或是突然開始運動,都要一個過程。用戶自己也會用真實世界裏面的直覺來預判View的變化,所以我們應該動畫的形式來完成,比如在PieChart裏面,我們要實現停止之後,小圓點位於所在餅切片的中間,這時我們就要用動畫的解決,而不是直接生硬的修改位置。

    private void centerOnCurrentItem() {
        Item current = mData.get(getCurrentItem());
        int targetAngle = current.mStartAngle + (current.mEndAngle - current.mStartAngle) / 2;
        targetAngle -= mCurrentItemAngle;
        if (targetAngle < 90 && mPieRotation > 180) targetAngle += 360;

        if (Build.VERSION.SDK_INT >= 11) {
            // Fancy animated version
            mAutoCenterAnimator.setIntValues(targetAngle);
            mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION).start();
        } else {
            // Dull non-animated version
            //mPieView.rotateTo(targetAngle);
        }
    }

優化View

簡而言之:

  • 讓你的佈局儘可能淺從而減少系統確定大小時的歷遍次數
  • 儘可能減少不必要的Invalidate()從未減少對onDraw()不必要的回調,
發佈了55 篇原創文章 · 獲贊 13 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章