自定義View#
基本約束##
Conform to Android standards
Provide custom styleable attributes that work with Android XML layouts
Send accessibility events
Be compatible with multiple Android platforms.
1. 符合Android標準
2. 提供一些自定義的樣式屬性,可以在layout中配置
3. 實現自己的events
4. 兼容Android各平臺
1.1 繼承一個 View
所有framework中提供的View類都繼承自View。我們的自定義View可以直接繼承View,也可以繼承View的子類,比如Button,TextView
必須實現下面的構造函數
class MyView extends View {
public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
}
}
1.2聲明自定義屬性##
1.2.1 在<declare-stylable>資源元素中定義我們的自定義屬性###
res/values/attrs.xml
<resources>
<declare-styleable name="MyView">
<attr name="showText" format="boolean" />
<attr name="labelPosition" format="enum">
<enum name="left" value="0"/>
<enum name="right" value="1"/>
</attr>
</declare-styleable>
</resources>
這部分代碼定義兩個屬性: showText和labelPosition,屬于MyView.這里的name最好跟我們自定義的View名字相同,不過這不是強制的,但是正常開發中一般都這么做。
完成上面的xml后,我們就可以在layout中給這些屬性賦值。就像Android提供的原生屬性一樣,唯一不同的是我們的自定義屬性屬于另外一個namespace.
http://schemas.android.com/apk/res/com.gome.farmpatner
默認的命名空間是
http://schemas.android.com/apk/res/android
根據上面的例子,在layout文件中,可以這樣定義屬性值
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res/com.gome.farmpatner">
<com.gome.farmpatner.MyView
custom:showText="true"
custom:labelPosition="left" />
</LinearLayout>
注意到這里,對于自定義控件我們引用的是全包名。
如果MyView是另一個類CustomizedView的內部類,那么需要這么寫:
com.gome.farmpatner.CustomizedView$MyView
1.2.2 應用自定義屬性###
當View從XML中創建之后,所有的屬性值都會從resource bundle中讀出來并存儲到一個AttributeSet中,這個AttributeSet最終會傳給我們view的構造函數。
應該使用Android提供的接口去解析AttributeSet,而不是直接讀取它,因為直接讀取有兩個缺點:
a. 屬性值的類型需要自己解決
需要手動解決資源值的類型getAttributeResourceValue(int, int),還有資源的查找也需要自己解決
具體的可以看http://192.168.63.218:8080/source/xref/GM025_CT_S06/frameworks/base/core/java/android/util/AttributeSet.java#20
https://developer.android.com/reference/android/util/AttributeSet.html
b. 樣式需要自己去應用
Android提供的接口會幫我們apply這些屬性到樣式中。
正確的使用方式是:
public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.MyView,
0, 0);
try {
mShowText = a.getBoolean(R.styleable.showText, false);
mTextPos = a.getInteger(R.styleable.labelPosition, 0);
} finally {
a.recycle();
}
}
記得TypeArray最后需要recycle()。
1.2.3 添加屬性和事件###
Attributes可以很方便的控制view的顯示和行為,不過這些值只能在view初始化完成之后才能獲取的到。
一般會提供一個動態的接口讓調用者去控制。也就是getter和setter。
public boolean isShowText() {
return mShowText;
}
public void setShowText(boolean showText) {
mShowText = showText;
invalidate();
requestLayout();
}
注意setter方法中,最后調用了invalidata和requestLayout,這樣View才會重新繪制和布局,才能將調用者想要的效果立馬顯示到View中。
如果屬性影響到View的展示,那么我們一定得調用invalidate()來通知系統對View進行重繪。
如果屬性值影響到view的大小或者形狀等布局類的內容,則一定要調用requestLayout來通知系統對View進行重新布局。
在自定義View中,可以根據需要暴露出一些event的相關接口,提供一個listener的interface供調用者實現。
對于本章節,最基本的規則就是:我們應該將那些會影響到View的展示和行為的property都給暴露出來。
1.2.4 Design For Accessibility###
這主要是Google提出來,為了殘障人士準備的。一些殘疾人可能看不見或者使用不了觸摸屏的用戶。
這部分內容具體可以看看https://developer.android.com/guide/topics/ui/accessibility/apps.html#custom-views
1.3 實現自定義的繪制
1.3.1 Override onDraw
onDraw(Canvas canvas)
在使用canvas繪制之前,我們先得有paint對象,下面就是paint的相關介紹
1.3.2 創建需要繪制的對象
android.graphics包將繪制的工作分為兩部分:
a. 畫什么, canvas
b. 怎么畫, paint
canvas決定需要繪制的形狀,而paint則定義顏色,樣式,字體等內容。
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(mTextColor);
if (mTextHeight == 0) {
mTextHeight = mTextPaint.getTextSize();
} else {
mTextPaint.setTextSize(mTextHeight);
}
提前創建paint對象是至關重要的優化手段,因為View會被重繪的很頻繁,如果我們每次都在onDraw中創建對象的話會相當影響程序性能。
1.3.3處理Layout相關事件###
為了能夠準確的繪制我們的自定義View,就得知道size是多大。
復雜一點的自定義View經常需要根據size和處于屏幕中的位置來執行多次layout計算。我們絕不可以假設我們的view在屏幕中占多大位置。即便是只有一個app使用我們的view,但是該app也得處理不同的屏幕尺寸,不同分辨率,以及橫豎屏這些不同情況下的展示。
如果沒有特別的需求,只要override onSizeChanged函數就好了。
當view的size確定后,因為一些原因size發生了變化,這時候會調用onSizeChanged().在onSizeChanged()里面計算位置、尺寸以及其他任何和view的size相關的值,盡量不要在draw繪制的時候去重新計算。
一旦view的size被賦值之后,layout manager就會假定這個size是包括了padding內邊距的值。所以我們在計算view的size的時候必須處理padding的值。可以看下面的例子
// 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;
// 計算出直徑
float diameter = Math.min(ww, hh);
==》如果想要更好的控制layout的參數,可以復寫onMeasure方法
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
MeasureSpec將mode和value打包進一個int中了,可以用位移操作獲取對應的值,不過MeasureSpec已經提供了對應的方法。
getMode(int measureSpec)
Extracts the mode from the supplied measure specification.
getSize(int measureSpec)
Extracts the size from the supplied measure specification.
有三種模式: 英文比較好理解
AT_MOST child can be as large as it wants up to the specified size
EXACTLY The parent has determined an exact size for the child.
UNSPECIFIED The parent has not imposed any constraint on the child.
具體看一下一個復寫該方法的例子:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// Try for a width based on our minimum
int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
int w = resolveSizeAndState(minw, widthMeasureSpec, 1);
// Whatever the width ends up being, ask for a height that would let the pie
// get as big as it can
int minh = MeasureSpec.getSize(w) - (int)mTextWidth + getPaddingBottom() + getPaddingTop();
int h = resolveSizeAndState(MeasureSpec.getSize(w) - (int)mTextWidth, heightMeasureSpec, 0);
setMeasuredDimension(w, h);
}
-> minw的計算包含了padding,就跟上面onSizeChanged()提到的一樣
-> resolveSizeAndState用來確定最終的寬高的大小
--------之前看過幾本書中,都是自己根據MeasureSpec的mode去計算,其原理跟Android提供的函數resolveSizeAndState是一樣的。
-> onMeasure沒有返回值,該方法使用setMeasuredDimension來傳遞結果,調用該方法是必須的,否則,會拋出異常。
1.3.4 Draw 繪制
一旦你初始化了一些必須的object,比如paint什么的,你就可以實現自己的onDraw函數了。
雖然每個view的繪制過程都不一樣,不過都有幾個通用的接口:
drawText: 繪制textsetTypeface設置字體,setColor設置顏色
drawRect、drawOval、drawArc: 繪制簡單的形狀,使用setStyle來設置是否填充內部,外邊線的繪制
drawPath: 繪制更為復雜的形狀,通過添加直線和曲線來創建一個自定義的Path對象,然后使用drawPath()繪制到view上,Path也可以使用setStyle.
setShader()&LinearGradient: 使用LinearGradient對象設置漸變填充,然后調用setShader來將LinearGradient應用到對應的shape中。
drawBitmap: 繪制bitmap
1.4 處理用戶交互
1.4.1 Input Gestures
用戶的操作會觸發相關的回調函數,并傳入相關的events,我們可以復寫這些callbacks來實現我們跟用戶交互的邏輯。
1.4.1.1 touch events
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
MotionEvent有TOUCH_DOWN, TOUCH_MOVE, TOUCH_UP
1.4.1.2 gesture
touch event比較簡單,還有一些手勢的event,包括tapping(按壓,長按?), pulling, pushing, flinging(拋,類似listview的滑動?), and zooming(縮放). Android提供了GestureDetector。
我們需要實現GestureDetector.OnGestureListener接口,來實現自己的處理邏輯。如果我們僅僅是想處理部分的手勢邏輯,那么我們可以選擇繼承GestureDetector.SimpleOnGestureListener. 下面就是一個例子:
class mListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
}
mDetector = new GestureDetector(PieChart.this.getContext(), new mListener());
onDown的return true代表我們的gesture希望處理接下來的一系列的事件,因為不管是touch還是gesture肯定都是以一個Down的操作開始的。如果這里return false,那么mListener其他的處理函數都不會被調用。
下面的代碼在onTouchEvent中判斷gesturelistener是否需要處理該事件
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean result = mDetector.onTouchEvent(event);
if (!result) {
if (event.getAction() == MotionEvent.ACTION_UP) {
stopScrolling();
result = true;
}
}
return result;
}
1.4.2 Create Physically Plausible Motion (創建模擬物理的動作)
gesture是一種比較強大的控制屏幕操作的方案,不過比較難以記憶,除非提供出一種物理上合理的操作。比如listview用力滑動一下,然后抬手,listview還會繼續滑動一定的距離,就類似物理上的慣性。Android中的一個例子就是fling gesture.
Scroll類是處理fling gesture的基礎
想要開始一個fling(就是一個拋動,在屏幕上快速滑動然后抬起手指),可以調用fling,參數是starting velocity(開始的速度),最大最小的xy坐標值。velocity的值我們可以直接提供GestureDetector計算給我們的。
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
mScroller.fling(currentX, currentY, velocityX / SCALE, velocityY / SCALE, minX, minY, maxX, maxY);
postInvalidate();
}
Tips:盡管GestureDetector提供的velocity在物理上是精準的,不過實際上會發現這個值會讓滑動變得很快,所以一般我們都會將velocityX和velocityY除以一個4或者8.
-->fling()函數幫助我們建立了fling的物理模型,然后,我們需要每隔一段時間調用Scroller.computeScrollOffset()來更新scroller。computeScrollOffset會通過物理模型計算出xy坐標的位置,然后更新scroller的內部狀態。可以調用getCurrX()和getCurrY()獲取到對應的值。
大部分view都是直接將Scroller的xy值傳遞給scrollTo。當然也可以使用其他動畫,比如rotate。
if (!mScroller.isFinished()) {
mScroller.computeScrollOffset();
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
}
Scroll會為我們計算出scroll的位置,但是它并不會自動把這些改動apply到View上。我們應該做的是確保以足夠的頻率來get和apply新的坐標到view上,這樣滾動的動畫才會平滑。一般,有兩種方式來實現:
-->在fling后調用postInvalidate(),強制重新繪制view,這種情況下我們就需要在onDraw中計算scroll的offset,并且每當offset改變的時候都要調用postInvalidate().
-->設置一個ValueAnimator,處理fling的過程,添加一個listener處理fling動畫的update,addUpdateListener.這種方式避免有時候view不必要的重繪。3.0之后支持
mScroller = new Scroller(getContext(), null, true);
mScrollAnimator = ValueAnimator.ofFloat(0,1);
mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
if (!mScroller.isFinished()) {
mScroller.computeScrollOffset();
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
} else {
mScrollAnimator.cancel();
onScrollFinished();
}
}
});
1.4.3 平滑動畫
Android屬性動畫 property animation framework.
每當我們有什么屬性發生變化的時候,并不是直接更新到view上去,而是使用valueAnimator去操作。
mAutoCenterAnimator = ObjectAnimator.ofInt(MyView.this, "testValue", 0);
mAutoCenterAnimator.setIntValues(targetAngle);
mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION);
mAutoCenterAnimator.start();
Tips:這部分后面研究
如果我們改變的是view的基本屬性值,那么就很簡單,直接使用Android封裝好的接口:
animate().rotation(targetAngle).setDuration(ANIM_DURATION).start();
1.5 View的優化##
要想界面流暢不丟幀,那么就要保證1秒鐘60幀左右。
為了增加View的流暢度,那么就要把一些不必要的代碼從需要頻繁調用的代碼中剝離出來。
-->先從onDraw開始,下面的策略會帶來很大的回報。首先應該避免在onDraw中創建對象,因為局部變量的allocation會頻繁喚醒GC從而有可能造成界面的卡頓。可以在初始化的時候或者多個動畫之間的時候去分配內存,但是切記不要在動畫執行的時候去執行allocation。
-->另外,盡量減少onDraw不必要的調用,大部分onDraw的回調都是因為invalidate()的調用,所以要減少invalidate()不必要的調用。
-->另外一個耗時操作是布局的遍歷。當我們調用requestLayout的時候,Android需要遍歷整個view的層級去確定每個view的size。如果存在一些沖突,那么可能會多次遍歷。保證你的ViewGroup的層級盡可能的少。
-->如果你要實現的是一個很復雜的UI,應該考慮自定一個ViewGroup。不同于自帶的views,自定義view可以根據應用場景對子view的大小和位置做一些預設和假定,因此會一定程度上避免多次遍歷children來layout。
比如把子view的大小和位置直接寫死,就不需要measure子view了。
參考:https://developer.android.com/training/custom-views/index.html