前言
今天看了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()不必要的回調,