在1/3中我們結束了全部的Paint方法學習還略帶地說了下Matri的簡單用法,這兩節呢,我們將甩掉第二個陌生又熟悉的情婦:Canvas。Canvas從我們該系列教程的第一節起就嘚啵嘚啵個沒完沒了,幾乎每個View都扯到了它,就像我之前說的那樣,自定義控件的關鍵一步就是如何去繪制控件,繪制說白了就是畫,既然要畫那么筆和紙是必須的,Canvas就是**Android**給我們的紙,彌足輕重,它決定了我們能畫什么:

上面所羅列出來的各種drawXXX方法就是Canvas中定義好的能畫什么的方法(drawPaint除外),除了各種基本型比如矩形圓形橢圓直曲線外Canvas也能直接讓我們繪制各種圖片以及顏色等等,但是Canvas真正屌的我覺得不是它能畫些什么,而是對畫布的各種活用,上一節最后的一個例子大家已經粗略見識了變換Canvas配合save和restore方法給我們繪制圖形帶來的極大便利,事實上Canvas的活用遠不止此,在講Canvas之前,我想先給大家說說Canvas中非常屌毛而且很有個性的一個方法:
`drawBitmapMesh(Bitmap bitmap, int meshWidth, int meshHeight, float[] verts, int vertOffset, int[] colors, int colorOffset, Paint paint) `
drawBitmapMesh是個很屌毛的方法,為什么這樣說呢?因為它可以對Bitmap做幾乎任何改變,是的,你沒聽錯,是任何,幾乎無所不能,這個屌毛方法我曾一度懷疑谷歌那些逗比為何將它屈尊在Canvas下,因為它對Bitmap的處理實在在強大了。上一節我們在講到Matrix的時候說過Matrix可以對我們的圖像做多種變換,實際上drawBitmapMesh也可以,只不過需要一點計算,比如我們可以使用drawBitmapMesh來模擬錯切skew的效果:

實現過程也非常非常簡單:
~~~
public class BitmapMeshView extends View {
private static final int WIDTH = 19;// 橫向分割成的網格數量
private static final int HEIGHT = 19;// 縱向分割成的網格數量
private static final int COUNT = (WIDTH + 1) * (HEIGHT + 1);// 橫縱向網格交織產生的點數量
private Bitmap mBitmap;// 位圖資源
private float[] verts;// 交點的坐標數組
public BitmapMeshView(Context context, AttributeSet attrs) {
super(context, attrs);
// 獲取位圖資源
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.gril);
// 實例化數組
verts = new float[COUNT * 2];
/*
* 生成各個交點坐標
*/
int index = 0;
float multiple = mBitmap.getWidth();
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH + ((HEIGHT - y) * 1.0F / HEIGHT * multiple);
setXY(fx, fy, index);
index += 1;
}
}
}
/**
* 將計算后的交點坐標存入數組
*
* @param fx
* x坐標
* @param fy
* y坐標
* @param index
* 標識值
*/
private void setXY(float fx, float fy, int index) {
verts[index * 2 + 0] = fx;
verts[index * 2 + 1] = fy;
}
@Override
protected void onDraw(Canvas canvas) {
// 繪制網格位圖
canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, verts, 0, null, 0, null);
}
}
~~~
其他的我就不說了,關鍵代碼就一段:
~~~
/*
* 生成各個交點坐標
*/
int index = 0;
float multiple = mBitmap.getWidth();
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH + ((HEIGHT - y) * 1.0F / HEIGHT * multiple);
setXY(fx, fy, index);
index += 1;
}
}
~~~
這段代碼生成了200個點的坐標數據全部存入verts數組,verts數組中,偶數位表示x軸坐標,奇數位表示y軸坐標,最終verts數組中的元素構成為:[x,y,x,y,x,y,x,y,x,y,x,y,x,y………………]共200 * 2=400個元素,為什么是400個?如果你不是蠢13的話一定能計算過來。那么現在我們一定很好奇,drawBitmapMesh到底是個什么個意思呢?,其實drawBitmapMesh的原理灰常簡單,它按照meshWidth和meshHeight這兩個參數的值將我們的圖片劃分成一定數量的網格,比如上面我們傳入的meshWidth和meshHeight均為19,意思就是把整個圖片橫縱向分成19份:

橫縱向19個網格那么意味著橫縱向分別有20條分割線對吧,這20條分割線交織又構成了20 * 20個交織點
每個點又有x、y兩個坐標……而drawBitmapMesh的verts參數就是存儲這些坐標值的,不過是圖像變化后的坐標值,什么意思?說起來有點抽象,借用國外大神的兩幅圖來理解:

如上圖,黃色的點是使用mesh分割圖像后分割線的交點之一,而drawBitmapMesh的原理就是通過移動這些點來改變圖像:

如上圖,移動黃色的點后,圖像被扭曲改變,你能想象在一幅剛畫好的油畫上有手指尖一抹的感覺么?油畫未干,手指抹過的地方必將被抹得一塌糊涂,drawBitmapMesh的原理就與之類似,只不過我們不常只改變一點,而是改變大量的點來達到效果,而參數verts則存儲了改變后的坐標,drawBitmapMesh依據這些坐標來改變圖像,如果上面的代碼中我們不將每行的x軸坐標進行平移而是單純地計算了一下均分后的各點坐標:
~~~
/*
* 生成各個交點坐標
*/
int index = 0;
// float multiple = mBitmap.getWidth();
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH;
// float fx = mBitmap.getWidth() * x / WIDTH + ((HEIGHT - y) * 1.0F / HEIGHT * multiple);
setXY(fx, fy, index);
index += 1;
}
}
~~~
你會發現圖像沒有任何改變,為什么呢?因為上面我們說過,verts表示了圖像變化后各點的坐標,而點坐標的變化是參照最原始均分后的坐標點,也就是圖:

中的各個交織點,在此基礎上形成變化,比如我們最開始的錯切效果,原理很簡單,我們這里把圖像分成了橫豎20條分割線(實際上錯切變換只需要四個頂點即可,這里我只作點稍復雜的演示),我們只需將第一行的點x軸向上移動一定距離,而第二行的點移動的距離則比第一行點稍短,依次類推即可,每行點移動的距離我們通過
`(HEIGHT - y) * 1.0F / HEIGHT * multiple `
來計算,最終形成錯切的效果
drawBitmapMesh不能存儲計算后點的值,每次調用drawBitmapMesh方法改變圖像都是以基準點坐標為參考的,也就是說,不管你執行drawBitmapMesh方法幾次,只要參數沒改變,效果不累加。
drawBitmapMesh可以做出很多很多的效果,比如類似放大鏡的:
~~~
/*
* 生成各個交點坐標
*/
int index = 0;
float multipleY = mBitmap.getHeight() / HEIGHT;
float multipleX = mBitmap.getWidth() / WIDTH;
for (int y = 0; y <= HEIGHT; y++) {
float fy = multipleY * y;
for (int x = 0; x <= WIDTH; x++) {
float fx = multipleX * x;
setXY(fx, fy, index);
if (5 == y) {
if (8 == x) {
setXY(fx - multipleX, fy - multipleY, index);
}
if (9 == x) {
setXY(fx + multipleX, fy - multipleY, index);
}
}
if (6 == y) {
if (8 == x) {
setXY(fx - multipleX, fy + multipleY, index);
}
if (9 == x) {
setXY(fx + multipleX, fy + multipleY, index);
}
}
index += 1;
}
}
~~~
這時我們將圖片眼睛附近的四個點外移到臨近的四個點上,圖像該區域就會被像放大一樣:

太惡心了……我們借助另外一個例子來更好地理解drawBitmapMesh,這個例子與API DEMO類似,我只是參考了國外大神的效果給他加上了一些標志點和位移線段來更好地展示drawBitmapMesh做了什么:
~~~
public class BitmapMeshView2 extends View {
private static final int WIDTH = 9, HEIGHT = 9;// 分割數
private static final int COUNT = (WIDTH + 1) * (HEIGHT + 1);// 交點數
private Bitmap mBitmap;// 位圖對象
private float[] matrixOriganal = new float[COUNT * 2];// 基準點坐標數組
private float[] matrixMoved = new float[COUNT * 2];// 變換后點坐標數組
private float clickX, clickY;// 觸摸屏幕時手指的xy坐標
private Paint origPaint, movePaint, linePaint;// 基準點、變換點和線段的繪制Paint
public BitmapMeshView2(Context context, AttributeSet set) {
super(context, set);
setFocusable(true);
// 實例畫筆并設置顏色
origPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
origPaint.setColor(0x660000FF);
movePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
movePaint.setColor(0x99FF0000);
linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
linePaint.setColor(0xFFFFFB00);
// 獲取位圖資源
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bt);
// 初始化坐標數組
int index = 0;
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH;
setXY(matrixMoved, index, fx, fy);
setXY(matrixOriganal, index, fx, fy);
index += 1;
}
}
}
/**
* 設置坐標數組
*
* @param array
* 坐標數組
* @param index
* 標識值
* @param x
* x坐標
* @param y
* y坐標
*/
private void setXY(float[] array, int index, float x, float y) {
array[index * 2 + 0] = x;
array[index * 2 + 1] = y;
}
@Override
protected void onDraw(Canvas canvas) {
// 繪制網格位圖
canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, matrixMoved, 0, null, 0, null);
// 繪制參考元素
drawGuide(canvas);
}
/**
* 繪制參考元素
*
* @param canvas
* 畫布
*/
private void drawGuide(Canvas canvas) {
for (int i = 0; i < COUNT * 2; i += 2) {
float x = matrixOriganal[i + 0];
float y = matrixOriganal[i + 1];
canvas.drawCircle(x, y, 4, origPaint);
float x1 = matrixOriganal[i + 0];
float y1 = matrixOriganal[i + 1];
float x2 = matrixMoved[i + 0];
float y2 = matrixMoved[i + 1];
canvas.drawLine(x1, y1, x2, y2, origPaint);
}
for (int i = 0; i < COUNT * 2; i += 2) {
float x = matrixMoved[i + 0];
float y = matrixMoved[i + 1];
canvas.drawCircle(x, y, 4, movePaint);
}
canvas.drawCircle(clickX, clickY, 6, linePaint);
}
/**
* 計算變換數組坐標
*/
private void smudge() {
for (int i = 0; i < COUNT * 2; i += 2) {
float xOriginal = matrixOriganal[i + 0];
float yOriginal = matrixOriganal[i + 1];
float dist_click_to_origin_x = clickX - xOriginal;
float dist_click_to_origin_y = clickY - yOriginal;
float kv_kat = dist_click_to_origin_x * dist_click_to_origin_x + dist_click_to_origin_y * dist_click_to_origin_y;
float pull = (float) (1000000 / kv_kat / Math.sqrt(kv_kat));
if (pull >= 1) {
matrixMoved[i + 0] = clickX;
matrixMoved[i + 1] = clickY;
} else {
matrixMoved[i + 0] = xOriginal + dist_click_to_origin_x * pull;
matrixMoved[i + 1] = yOriginal + dist_click_to_origin_y * pull;
}
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
clickX = event.getX();
clickY = event.getY();
smudge();
invalidate();
return true;
}
}
~~~
運行后的效果如下:

大波妹子圖上我們繪制了很多藍色和紅色的點,默認狀態下,藍色和紅色的點是重合在一起的,兩者間通過一線段連接,當我們手指在圖片上移動時,會出現一個黃色的點,黃色的點代表我們當前的觸摸點,而紅色的點代表變換后的坐標點,藍色的點代表基準坐標點:

可以看到越靠近觸摸點的紅點越向觸摸點坍塌,紅點表示當前變換后的點坐標,藍點表示基準點的坐標,所有的變化都是參照藍點進行的,這個例子可以很容易地理解drawBitmapMesh:

大波妹子揉啊揉~~~~揉啊揉~~~~
drawBitmapMesh參數中有個vertOffset,該參數是verts數組的偏移值,意為從第一個元素開始才對位圖就行變化,這些大家自己去嘗試下吧,還有colors和colorOffset,類似。
drawBitmapMesh說實話真心很屌,但是計算復雜確是個雞肋,這么屌的一個方法被埋沒其實是由原因可循的,高不成低不就,如上所示,有些變換我們可以使用Matrix等其他方法簡單實現,但是drawBitmapMesh就要通過一些列計算,太復雜。那真要做復雜的圖形效果呢,考慮到效率我們又會首選OpenGL……這真是一個悲傷的故事……無論怎樣,請記住這位烈士一樣的方法…………總有用處的
好了,真的要開始搞Canvas,開始搞了哦~~誰先上?
要學懂Canvas就要知道Canvas的本質是什么,那有盆友就會說了,麻痹你不是扯過無數次Canvas是畫布么,難道又不是了?是,Canvas是畫布,但是我們真的是在Canvas上畫東西么?在前幾節的一些例子中我們曾這樣使用過Canvas:
~~~
Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawColor(Color.RED);
~~~
也就是說將Bitmap注入到Canvas中,爾后Canvas所有的操作都會在這個Bitmap上進行,如果,此時我們的界面中有一個ImageView,那么我們可以直接將繪制后的Bitmap顯示出來:
~~~
public class MainActivity extends Activity {
private ImageView ivMain;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ivMain = (ImageView) findViewById(R.id.main_iv);
Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawColor(Color.RED);
ivMain.setImageBitmap(bitmap);
}
}
~~~
運行效果如圖所示:

我們只是簡單地填充了一塊紅色色塊,色塊的大小由bitmap決定,更確切地說,這個Canvas的大小是由bitmap決定的,類似的方法我們在前幾節的例子中也不少用到,這里就不多說了。除了我們自己去new一個Canvas外,我們更常獲得Canvas對象的地方是在View的:
~~~
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
~~~
在這里通過onDraw方法的參數傳遞我們可以獲取一個Canvas對象,好奇的同學一定很想知道這個Canvas對象是如何來的,跟我們自己new的有何區別。事實上兩者區別不大,最終都是new過來的,只是onDraw方法傳過來的Canvas對象擁有一些繪制的上下文關聯。這一過程涉及到太多的源碼,這里我只簡單地提一下。在framework中,Activty被創建時(更準確地說是在addView的時候)會同時創建一個叫做ViewRootImpl的對象,ViewRootImpl是個很碉堡的類,它負責很多GUI的東西,包括我們常見的窗口顯示、用戶的輸入輸出等等,同時,它也負責Window跟WMS通信(Window你可以想象是一個容器,里面包含著我們的一個Activity,而AMS呢全稱為Activity Manager Service,顧名思義很好理解它的作用),當ViewRootImpl跟WMS建立通信注冊了Window后就會發出第一次渲染View Hierachy的請求,涉及到的方法均在ViewRootImpl下:setView、requestLayout、scheduleTraversals等,大家有興趣可以自己去搜羅看看,在performTraversals方法中ViewRootImpl就會去創建Surface,而此后的渲染則可以通過Surface的lockCanvas方法獲取Surface的Canvas來進行,然后遍歷View Hierachy把需要繪制的View通過Canvas(View.onDraw(Canvas canvas))繪制到Surface上,繪制完成后解鎖(Surface.unlockCanvasAndPost)讓SurfaceFlinger將Surface繪制到屏幕上。我們onDraw(Canvas canvas)方法中傳入的Canvas對象大致就是這么來的,說起簡單,其實中間還有大量的過程被我省略了………………還是不扯為好,扯了講通宵都講不完。
上面我們概述了下onDraw參數列表中的Canvas對象是怎么來的,那么Canvas的實質是什么呢?我們通過追蹤Canvas的兩個構造方法可以發現兩者的實現過程:
無參構造方法:
~~~
/**
* Construct an empty raster canvas. Use setBitmap() to specify a bitmap to
* draw into. The initial target density is {@link Bitmap#DENSITY_NONE};
* this will typically be replaced when a target bitmap is set for the
* canvas.
*/
public Canvas() {
if (!isHardwareAccelerated()) {
// 0 means no native bitmap
mNativeCanvas = initRaster(0);
mFinalizer = new CanvasFinalizer(mNativeCanvas);
} else {
mFinalizer = null;
}
}
~~~
含Bitmap對象作為參數的構造方法:
~~~
/**
* Construct a canvas with the specified bitmap to draw into. The bitmap
* must be mutable.
*
* <p>The initial target density of the canvas is the same as the given
* bitmap's density.
*
* @param bitmap Specifies a mutable bitmap for the canvas to draw into.
*/
public Canvas(Bitmap bitmap) {
if (!bitmap.isMutable()) {
throw new IllegalStateException("Immutable bitmap passed to Canvas constructor");
}
throwIfCannotDraw(bitmap);
mNativeCanvas = initRaster(bitmap.ni());
mFinalizer = new CanvasFinalizer(mNativeCanvas);
mBitmap = bitmap;
mDensity = bitmap.mDensity;
}
~~~
大家看到這兩個構造方法我都把它的注釋給COPY出來了,目的就是想告訴大家,雖然說無參的構造方法并沒有傳入Bitmap對象,但是Android依然建議(苛刻地說是要求)我們使用Canvas的setBitmap()方法去為Canvas指定一個Bitmap對象!為什么Canvas非要一樣Bitmap對象呢?原因很簡單,Canvas需要一個Bitmap對象來保存像素。Canvas有大量的代碼被封裝并通過jni調用,事實上Android涉及圖形圖像處理的大量方法都是通過jni調用的,比如上面兩個構造方法都調用了一個initRaster方法,這個方法的實現灰常簡單:
~~~
static SkCanvas* initRaster(JNIEnv* env, jobject, SkBitmap* bitmap) {
if (bitmap) {
return new SkCanvas(*bitmap);
} else {
// Create an empty bitmap device to prevent callers from crashing
// if they attempt to draw into this canvas.
SkBitmap emptyBitmap;
return new SkCanvas(emptyBitmap);
}
}
~~~
可以看到bitmap又被封裝成了一個SkCanvas對象。上面我們曾說過,onDraw中傳來的Cnavas對象來自于ViewRootImpl的Surface,當調用Surface.lockCanvas時會從圖像緩存隊列中取出一個可用緩存,把當前Posted Buffer的內容COPY到新緩存中然后加鎖該緩存區域并設置為Locked Buffer。此時會根據新緩存的內存地址構建一個SkBitmap并將該SkBitmap設置到SkCanvas中并返回與之對應Canvas。而當調用Surface.unlockCanvasAndPost時則會清空SkCanvas并將SkBitmap設置為空,此時Locked Buffer將會被解鎖并重新扔回圖像緩存隊列中,同時將Poated Buffer設置為Locked Buffer,舊的Posted Buffer就可以被下次取出來使用,設置Locked Buffer為空,當SF下次進行screen composite的時候就會把當前Poated Buffer繪制到屏幕上,這算是Canvas到屏幕繪制的一個小過程,當然事實比我說的復雜得多,這又是我的一個刪減版本而已,懂得就聽,不懂的權當廢話不用管,我們不會涉及到這么深,像什么HardwareCanvas、GL之類的太過深入沒必要去學,這里只闡述一個小原理而已。
對我們普通開發者來說,要記住的的是,一個Canvas需要一個Bitmap來保存像素信息,你說不要行不行?當然可以,畫得東西沒法保存而已,既然沒法保存那我畫來還有何意義呢?isn't it?
Canvas所提供的各種方法根據功能來看大致可以分為幾類,第一是以drawXXX為主的繪制方法,第二是以clipXXX為主的裁剪方法,第三是以scale、skew、translate和rotate組成的Canvas變換方法,最后一類則是以saveXXX和restoreXXX構成的畫布鎖定和還原,還有一些渣渣方法就不歸類了。
繪制圖形、變換鎖定還原畫布我們都在前面的一些code中使用過,那么什么叫裁剪畫布呢?我們來看一段code:
~~~
public class CanvasView extends View {
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE);
canvas.clipRect(0, 0, 500, 500);
canvas.drawColor(Color.RED);
}
}
~~~
這段代碼灰常簡單,我們在onDraw中將整個畫布繪制成藍色,然后我們在當前畫布上從[0,0]為左端點開始裁剪出一塊500x500大小的矩形,再次將畫布繪制成紅色,你會發現只有被裁剪的區域才能被繪制成紅色:

是不是有點懂裁剪的意思了?不懂?沒事,我們再畫一個圓加深理解:
~~~
public class CanvasView extends View {
private Paint mPaint;
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.GREEN);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE);
canvas.clipRect(0, 0, 500, 500);
canvas.drawColor(Color.RED);
canvas.drawCircle(500, 600, 100, mPaint);
}
}
~~~
如代碼所示,我們在以[500,600]為圓心繪制一個半徑為100px的綠色圓,按道理來說,這個圓應該剛好與紅色區域下方相切對吧,但是事實上呢我們見不到任何效果,為什么?因為如上所說,當前畫布被“裁剪”了,只有500x500也就是上圖中紅色區域的大小了,如果我們所繪制的東西在該區域外部,即便繪制了你也看不到,這時我們稍增大圓的半徑:
`canvas.drawCircle(500, 600, 150, mPaint); `

終于看到我們的圓“露”出來了~~現在你能稍微明白裁剪的作用了么?上面的代碼中我們使用到了Canvas的
`clipRect(int left, int top, int right, int bottom) `
方法,與之類似的還有
`clipRect(float left, float top, float right, float bottom) `
方法,一個int一個float,不扯了。除此之外還有兩個與之對應的方法
~~~
clipRect(Rect rect)
clipRect(RectF rect)
~~~
,Rect和RectF是類似的,只不過RectF中涉及計算的時候數值類型均為float型,兩者均表示一塊規則矩形,何以見得呢?我們以Rect為例來Test一下:
~~~
public class CanvasView extends View {
private Rect mRect;
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
mRect = new Rect(0, 0, 500, 500);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE);
canvas.clipRect(mRect);
canvas.drawColor(Color.RED);
}
}
~~~
如代碼所示這樣我們得到的結果跟上面的結果并無二致,藍色的底,500x500大小的紅色矩形,但是Rect的意義遠不止于此,鑒于Rect類并不復雜,我就講兩個其比較重要的方法,我們稍微更改下我們的代碼:
~~~
public class CanvasView extends View {
private Rect mRect;
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
mRect = new Rect(0, 0, 500, 500);
mRect.intersect(250, 250, 750, 750);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE);
canvas.clipRect(mRect);
canvas.drawColor(Color.RED);
}
}
~~~
大家看到我在實例化了一個Rect后調用了intersect方法,這個方法的作用是什么?來看看效果先:

PS:黃色線框為后期加上的輔助線非程序生成
可以看到原先的紅色區域變小了,這是怎么回事呢?其實intersect的作用跟我們之前學到的圖形混合模式有點類似,它會取兩個區域的相交區域作為最終區域,上面我們的第一個區域是在實例化Rect時確定的(0, 0, 500, 500),第二個區域是調用intersect方法時指定的(250, 250, 750, 750),這兩個區域對應上圖的兩個黃色線框,兩者相交的地方則為最終的紅色區域,而intersect方法的計算方式是相當有趣的,它不是單純地計算相交而是去計算相交區域最近的左上端點和最近的右下端點,不知道大家是否明白這個意思,我們來看Rect中的另一個union方法你就會懂,union方法與intersect相反,取的是相交區域最遠的左上端點作為新區域的左上端點,而取最遠的右下端點作為新區域的右下端點,比如:
`mRect.union(250, 250, 750, 750); `
運行后我們會看到如下結果:

是不是覺得不是我們想象中的那樣單純地兩個區域相加?沒事,好好體會,后面還有類似的。類似的方法Rect和RectF都有很多,效果都是顯而易見的就不多說了,有興趣大家可以自己去try。
說到這里會有很多童鞋會問,裁剪只是個矩形區域,如果我想要更多不規則的裁剪區域怎么辦呢?別擔心,Android必然也考慮到這樣的情況,其提供了一個
`clipPath(Path path) `
方法給我們以Path的方式創建更多不規則的裁剪區域,在1/4講PathEffect的時候我們曾對Path有所接觸,但是依舊不了解
Path是android中用來封裝幾何學路徑的一個類,因為Path在圖形繪制上占的比重還是相當大的,這里我們先來學習一下這個Path,來看看其一些具體的用法:
~~~
public class PathView extends View {
private Path mPath;// 路徑對象
private Paint mPaint;// 畫筆對象
public PathView(Context context, AttributeSet attrs) {
super(context, attrs);
/*
* 實例化畫筆并設置屬性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN);
// 實例化路徑
mPath = new Path();
// 連接路徑到點[100,100]
mPath.lineTo(100, 100);
}
@Override
protected void onDraw(Canvas canvas) {
// 繪制路徑
canvas.drawPath(mPath, mPaint);
}
}
~~~
這里我們用到了Path的一個方法
`lineTo(float x, float y) `
該方法很簡單咯,顧名思義將路徑連接至某個坐標點,事實也是如此:

注意,當我們沒有移動Path的點時,其默認的起點為畫布的[0,0]點,當然我們可以通過
`moveTo(float x, float y) `
方法來改變這個起始點的位置:
~~~
// 實例化路徑
mPath = new Path();
//移動點至[300,300]
mPath.moveTo(300, 300);
// 連接路徑到點[100,100]
mPath.lineTo(100, 100);
~~~
效果如下:

當然我們可以考慮多次調用lineTo方法來繪制更復雜的圖形:
~~~
// 實例化路徑
mPath = new Path();
// 移動點至[300,300]
mPath.moveTo(100, 100);
// 連接路徑到點
mPath.lineTo(300, 100);
mPath.lineTo(400, 200);
mPath.lineTo(200, 200);
~~~
一個沒有封閉的類似平行四邊形的線條:

如果此時我們想閉合該曲線讓它變成一個形狀該怎么做呢?聰明的你一定想到
`mPath.lineTo(100, 100) `
然而Path給我提供了更便捷的方法
`close() `
去閉合曲線:
~~~
// 實例化路徑
mPath = new Path();
// 移動點至[300,300]
mPath.moveTo(100, 100);
// 連接路徑到點
mPath.lineTo(300, 100);
mPath.lineTo(400, 200);
mPath.lineTo(200, 200);
// 閉合曲線
mPath.close();
~~~

那么有些朋友會問Path就只能光繪制這些單調的線段么?肯定不是!Path在繪制的方法中提供了許多XXXTo的方法來幫助我們繪制各類直線、曲線,例如,方法
`quadTo(float x1, float y1, float x2, float y2) `
可以讓我們繪制二階貝賽爾曲線,什么叫貝賽爾曲線?其實很簡單,使用三個或多個點來確定的一條曲線,貝塞爾曲線在圖形圖像學中有相當重要的地位,Path中也提供了一些方法來給我們模擬低階貝賽爾曲線。
貝塞爾曲線的定義也比較簡單,你只需要一個起點、一個終點和至少零個控制點則可定義一個貝賽爾曲線,當控制點為零時,只有起點和終點,此時的曲線說白了就是一條線段,我們稱之為
PS:以下圖片和公式均來自維基百科和互聯網
一階貝賽爾曲線

其公式可概括為:

其中B(t)為時間為t時點的坐標,P0為起點、Pn為終點
貝塞爾曲線于1962年由法國數學家Pierre Bézier第一次研究使用并給出了詳細的計算公式,So該曲線也是由其名字命名。Path中給出的quadTo方法屬于
二階貝賽爾曲線

二階貝賽爾曲線的一個明顯特征是其擁有一個控制點,大家可以這樣想想貝賽爾曲線,在一根兩端固定橡皮筋上有一塊磁鐵,現在我們拿另一塊磁鐵去吸引橡皮筋上的磁鐵,因為引力,橡皮筋會隨著我們手上磁鐵的移動而改變形狀,又因為橡皮筋的張力讓束縛在橡皮筋上的磁鐵不會輕易吸附到我們手上的磁鐵,這時橡皮筋的狀態就可以看成是一條貝塞爾曲線,而我們手中的磁鐵就是一個控制點,通過這個控制點我們“拉扯”橡皮筋的曲度。
二階貝賽爾曲線的公式為:

同樣的,Path中也提供了三階貝塞爾曲線的方法cubicTo,按照上面我們的推論,三階應該是有兩個控制點才對對吧
三階貝賽爾曲線

公式:

高階貝賽爾曲線在Path中沒有對應的方法,對我們來說三階也足夠了,不過大家可以了解下,難得我在墻外找到如此動感的貝賽爾曲線高清無碼動圖
高階貝塞爾曲線
四階:

五階:

貝塞爾曲線通用公式:

回到我們Path的quadTo方法,我們可以使用它來繪制一條曲線:
~~~
// 實例化路徑
mPath = new Path();
// 移動點至[100,100]
mPath.moveTo(100, 100);
// 連接路徑到點
mPath.quadTo(200, 200, 300, 100);
~~~
看圖說話:

其中quadTo的前兩個參數為控制點的坐標,后兩個參數為終點坐標,至于起點嘛……這么二的問題就別問了……是不是很簡單?如果你這么認為那就太小看貝塞爾曲線了。在我們對Path有一定的了解后會使用Path和裁剪做個有趣的東西,接著看Path的三階貝賽爾曲線:
`cubicTo(float x1, float y1, float x2, float y2, float x3, float y3) `
與quadTo類似,前四個參數表示兩個控制點,最后兩個參數表示終點:
~~~
// 實例化路徑
mPath = new Path();
// 移動點至[100,100]
mPath.moveTo(100, 100);
// 連接路徑到點
mPath.cubicTo(200, 200, 300, 0, 400, 100);
~~~
很好理解:

貝塞爾曲線是圖形圖像學中相當重要的一個概念,活用它可以得到很多很有意思的效果,比如,我在界面中簡單模擬一下杯子中水消匿的效果:

當然你也可以反過來讓模擬往杯子里倒水的效果~實現過程非常簡單,說白了就是不斷移動二階曲線的控制點同時不斷更改頂部各點的Y坐標,然后不斷重繪:
~~~
public class WaveView extends View {
private Path mPath;// 路徑對象
private Paint mPaint;// 畫筆對象
private int vWidth, vHeight;// 控件寬高
private float ctrX, ctrY;// 控制點的xy坐標
private float waveY;// 整個Wave頂部兩端點的Y坐標,該坐標與控制點的Y坐標增減幅一致
private boolean isInc;// 判斷控制點是該右移還是左移
public WaveView(Context context, AttributeSet attrs) {
super(context, attrs);
// 實例化畫筆并設置參數
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setColor(0xFFA2D6AE);
// 實例化路徑對象
mPath = new Path();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// 獲取控件寬高
vWidth = w;
vHeight = h;
// 計算控制點Y坐標
waveY = 1 / 8F * vHeight;
// 計算端點Y坐標
ctrY = -1 / 16F * vHeight;
}
@Override
protected void onDraw(Canvas canvas) {
/*
* 設置Path起點
* 注意我將Path的起點設置在了控件的外部看不到的區域
* 如果我們將起點設置在控件左端x=0的位置會使得貝塞爾曲線變得生硬
* 至于為什么剛才我已經說了
* 所以我們稍微讓起點往“外”走點
*/
mPath.moveTo(-1 / 4F * vWidth, waveY);
/*
* 以二階曲線的方式通過控制點連接位于控件右邊的終點
* 終點的位置也是在控件外部
* 我們只需不斷讓ctrX的大小變化即可實現“浪”的效果
*/
mPath.quadTo(ctrX, ctrY, vWidth + 1 / 4F * vWidth, waveY);
// 圍繞控件閉合曲線
mPath.lineTo(vWidth + 1 / 4F * vWidth, vHeight);
mPath.lineTo(-1 / 4F * vWidth, vHeight);
mPath.close();
canvas.drawPath(mPath, mPaint);
/*
* 當控制點的x坐標大于或等于終點x坐標時更改標識值
*/
if (ctrX >= vWidth + 1 / 4F * vWidth) {
isInc = false;
}
/*
* 當控制點的x坐標小于或等于起點x坐標時更改標識值
*/
else if (ctrX <= -1 / 4F * vWidth) {
isInc = true;
}
// 根據標識值判斷當前的控制點x坐標是該加還是減
ctrX = isInc ? ctrX + 20 : ctrX - 20;
/*
* 讓“水”不斷減少
*/
if (ctrY <= vHeight) {
ctrY += 2;
waveY += 2;
}
mPath.reset();
// 重繪
invalidate();
}
}
~~~
除了上面的幾個XXXTo外,Path還提供了一個
`arcTo (RectF oval, float startAngle, float sweepAngle) `
方法用來生成弧線,其實說白了就是從圓或橢圓上截取一部分而已 = =
~~~
// 實例化路徑
mPath = new Path();
// 移動點至[100,100]
mPath.moveTo(100, 100);
// 連接路徑到點
RectF oval = new RectF(100, 100, 200, 200);
mPath.arcTo(oval, 0, 90);
~~~
效果如下圖:

這里要注意哦,使用Path生成的路徑必定都是連貫的,雖然我們使用arcTo繪制的是一段弧但其最終都會與我們的起始點[100,100]連接起來,如果你不想連怎么辦?簡單,強制讓arcTo繪制的起點作為Path的起點不就是了?Path也提供了另一個重載方法:
`arcTo (RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo) `
該方法只是多了一個布爾值,值為true時將會把弧的起點作為Path的起點:
`mPath.arcTo(oval, 0, 90, true); `
like below:

Path中除了上面介紹的幾個XXXTo方法外還有一套rXXXTo方法:
~~~
rCubicTo(float x1, float y1, float x2, float y2, float x3, float y3)
rLineTo(float dx, float dy)
rMoveTo(float dx, float dy)
rQuadTo(float dx1, float dy1, float dx2, float dy2)
~~~
這一系列rXXXTo方法其實跟上面的那些XXXTo差不多的,唯一的不同是rXXXTo方法的參考坐標是相對的而XXXTo方法的參考坐標始終是參照畫布原點坐標,什么意思呢?舉個簡單的例子:
~~~
public class PathView extends View {
private Path mPath;// 路徑對象
private Paint mPaint;// 畫筆對象
public PathView(Context context, AttributeSet attrs) {
super(context, attrs);
/*
* 實例化畫筆并設置屬性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN);
mPaint.setStrokeWidth(5);
// 實例化路徑
mPath = new Path();
// 移動點至[100,100]
mPath.moveTo(100, 100);
// 連接路徑到點
mPath.lineTo(200, 200);
}
@Override
protected void onDraw(Canvas canvas) {
// 繪制路徑
canvas.drawPath(mPath, mPaint);
}
}
~~~
上述代碼我們從點[100,100]開始連接點[200,200]構成了一條線段:

這個點[200,200]是相對于畫布圓點坐標[0,0]而言的,這點大家應該好理解,如果我們換成
`mPath.rLineTo(200, 200); `
那么它的意思就是將會以[100,100]作為原點坐標,連接以其為原點坐標的坐標點[200,200],如果換算成一畫布原點的話,實際上現在的[200,200]就是[300,300]了:

懂了么?而這個前綴r也就是relative(相對)的簡寫,so easy是么!頭腦簡單!
XXXTo方法可以連接Path中的曲線而Path提供的另一系列addXXX方法則可以讓我們直接往Path中添加一些曲線,比如
`addArc(RectF oval, float startAngle, float sweepAngle) `
方法允許我們將一段弧形添加至Path,注意這里我用到了“添加”這個詞匯,也就是說,通過addXXX方法添加到Path中的曲線是不會和上一次的曲線進行連接的:
~~~
public class PathView extends View {
private Path mPath;// 路徑對象
private Paint mPaint;// 路徑畫筆對象
public PathView(Context context, AttributeSet attrs) {
super(context, attrs);
/*
* 實例化畫筆并設置屬性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN);
mPaint.setStrokeWidth(5);
// 實例化路徑
mPath = new Path();
// 移動點至[100,100]
mPath.moveTo(100, 100);
// 連接路徑到點
mPath.lineTo(200, 200);
// 添加一條弧線到Path中
RectF oval = new RectF(100, 100, 300, 400);
mPath.addArc(oval, 0, 90);
}
@Override
protected void onDraw(Canvas canvas) {
// 繪制路徑
canvas.drawPath(mPath, mPaint);
}
}
~~~

如圖和代碼所示,雖然我們先繪制了由[100,100]到[200,200]的線段,但是在我們往Path中添加了一條弧線后該弧線并沒與線段連接。除了addArc,Path還提供了一系列的add方法
~~~
addCircle(float x, float y, float radius, Path.Direction dir)
addOval(float left, float top, float right, float bottom, Path.Direction dir)
addRect(float left, float top, float right, float bottom, Path.Direction dir)
addRoundRect(float left, float top, float right, float bottom, float rx, float ry, Path.Direction dir)
~~~
這些方法和addArc有很明顯的區別,就是多了一個Path.Direction參數,其他呢都大同小異,除此之外不知道大家還發現沒有,addArc是往Path中添加一段弧,說白了就是一條開放的曲線,而上述幾種方法都是一個具體的圖形,或者說是一條閉合的曲線,Path.Direction的意思就是標識這些閉合曲線的閉合方向。那什么叫閉合方向呢?光說大家一定會蒙,有學習激情的童鞋看到后肯定會馬上敲代碼試驗一下兩者的區別,可是不管你如何改,單獨地在一條閉合曲線上你是看不出所謂閉合方向的區別的,這時我們可以借助Canvas的另一個方法來簡單地說明一下
`drawTextOnPath(String text, Path path, float hOffset, float vOffset, Paint paint) `
這個方法呢很簡單沿著Path繪制一段文字,參數也是一看就該懂得了不多說。Path.Direction只有兩個常量值CCW和CW分別表示逆時針方向閉合和順時針方向閉合,我們來看一段代碼
~~~
public class PathView extends View {
private Path mPath;// 路徑對象
private Paint mPaint;// 路徑畫筆對象
private TextPaint mTextPaint;// 文本畫筆對象
public PathView(Context context, AttributeSet attrs) {
super(context, attrs);
/*
* 實例化畫筆并設置屬性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN);
mPaint.setStrokeWidth(5);
mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG | Paint.LINEAR_TEXT_FLAG);
mTextPaint.setColor(Color.DKGRAY);
mTextPaint.setTextSize(20);
// 實例化路徑
mPath = new Path();
// 添加一條弧線到Path中
RectF oval = new RectF(100, 100, 300, 400);
mPath.addOval(oval, Path.Direction.CW);
}
@Override
protected void onDraw(Canvas canvas) {
// 繪制路徑
canvas.drawPath(mPath, mPaint);
// 繪制路徑上的文字
canvas.drawTextOnPath("ad撒發射點發放士大夫斯蒂芬斯蒂芬森啊打掃打掃打掃達發達省份撒旦發射的", mPath, 0, 0, mTextPaint);
}
}
~~~
我們往Path中添加了一條閉合方向為CW橢圓形的閉合曲線并將其繪制在Canvas上,同時呢我們沿著該曲線繪制了一段文本,效果如下:

如果我們把閉合方向改為CCW那么會發生什么呢?
`mPath.addOval(oval, Path.Direction.CCW); `

沿著Path的文字全都在閉合曲線的“內部”了,Path.Direction閉合方向大概就是這么個意思。對于我們平時開發來說,掌握Path的以上一些方法已經是足夠了,當然Path的方法還有很多,但是因為平時開發涉及的少,我也就不累贅了,畢竟用得少或者根本不會用到的東西說了也是浪費口水,對吧。
Path用的也相當廣泛,在之前的章節中我們也講過一個PathEffect類,兩者結合可以得到很多很酷的效果。在眾多的用途中,使用Path做折線圖算是最最最常見的了,僅僅使用以上我們講到的一些Path的方法可以完成很多的折線圖效果。
在上一節最后的一個例子中我們繪制了一個自定義的圈圈View,當時我跟大家說過在你想去自定義一個控件的時候一定要把自己看作一個designer而不是coder,你要用設計的眼光去看待一個控件,那么我們在做一個折線圖的控件之前就應該要分析一個折線圖應該是怎樣的,下面我google一些簡單折線圖的例子:

這種比較簡單

這種呢有文字標注稍難

這種就復雜了點
不管是哪種折線圖,我們都可以發現其必有一個橫坐標和一個縱坐標且其上都有刻度,一般情況下來說橫縱坐標上的刻度數量是一樣的。對于平面折線圖來說,分析到上面一點就差不多了,而我們要做的折線圖控件我在PS里簡單地做了一個design:

設計地很簡單,當中有一些輔助參數什么的,實際上整個控件就幾個元素:

如上圖所示,兩個帶刻度的軸和一個網格還有兩個軸文字標識和一條曲線,very simple!圖好像很簡單~~但是真要code起來就不是件容易的事了,首先我們要考慮到不同的數據、其次是屏幕的適配,說到適配,上一節我們曾講過,因為屏幕的多元化,我們必定不能寫死一個參數,so~我們在上一節畫圈圈的時候是使用控件的邊長來作為所有數值的基準參考,這次也一樣。
因為折線圖的形狀是跟外部數據相關的,所以在設計的時候我們必定要考慮到對外公布一個設置數據的方法:
~~~
/**
* 設置數據
*
* @param pointFs
* 點集合
*/
public synchronized void setData(List<PointF> pointFs, String signX, String signY) {
/*
* 數據為空直接GG
*/
if (null == pointFs || pointFs.size() == 0)
throw new IllegalArgumentException("No data to display !");
/*
* 控制數據長度不超過10個
* 對于折線圖來說數據太多就沒必要用折線圖表示了而是使用散點圖
*/
if (pointFs.size() > 10)
throw new IllegalArgumentException("The data is too long to display !");
// 設置數據并重繪視圖
this.pointFs = pointFs;
this.signX = signX;
this.signY = signY;
invalidate();
}
~~~
折線圖是表示的數據一般不會太多,如果太多,在有限的先是空間內必定顯示雞肋……當然股票那種巨幅大盤之類的另說。所以在上面的數據設置中我強制將數據長度控制在10個以內。
PS:該方法在設計上不太符合設計原則,這里就當大家都不會設計模式設計原則了 = = Fuck……
上面我們說過會以控件的邊長作為基準參考計算各種數值,因為我們還沒學習如何測量控件,這里還是和上一節一樣強制將控件的寬高設置一致(強制豎屏):
~~~
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 在我們沒學習測量控件之前強制寬高一致
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
~~~
而控件尺寸我們在onSizeChanged方法中獲取,這個方法是官方比較推崇的獲取控件尺寸的方法,如果你不需要更精確的測量的話,同時我們也就將就在該方法內計算各類數值了:
~~~
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// 獲取控件尺寸
viewSize = w;
// 計算縱軸標識文本坐標
textY_X = viewSize * TIME_X;
textY_Y = viewSize * TIME_Y;
// 計算橫軸標識文本坐標
textX_X = viewSize * MONEY_X;
textX_Y = viewSize * MONEY_Y;
// 計算xy軸標識文本大小
textSignSzie = viewSize * TEXT_SIGN;
// 計算網格左上右下兩點坐標
left = viewSize * LEFT;
top = viewSize * TOP;
right = viewSize * RIGHT;
bottom = viewSize * BOTTOM;
// 計算粗線寬度
thickLineWidth = viewSize * THICK_LINE_WIDTH;
// 計算細線寬度
thinLineWidth = viewSize * THIN_LINE_WIDTH;
}
~~~
其中的常量值均為比例值,根據控件中元素占比計算實際的像素大小,繪制邏輯稍微有點復雜,但是并不難,這里我就直接上全部代碼了:
~~~
public class PolylineView extends View {
private static final float LEFT = 1 / 16F, TOP = 1 / 16F, RIGHT = 15 / 16F, BOTTOM = 7 / 8F;// 網格區域相對位置
private static final float TIME_X = 3 / 32F, TIME_Y = 1 / 16F, MONEY_X = 31 / 32F, MONEY_Y = 15 / 16F;// 文字坐標相對位置
private static final float TEXT_SIGN = 1 / 32F;// 文字相對大小
private static final float THICK_LINE_WIDTH = 1 / 128F, THIN_LINE_WIDTH = 1 / 512F;// 粗線和細線相對大小
private TextPaint mTextPaint;// 文字畫筆
private Paint linePaint, pointPaint;// 線條畫筆和點畫筆
private Path mPath;// 路徑對象
private Bitmap mBitmap;// 繪制曲線的Btimap對象
private Canvas mCanvas;// 裝載mBitmap的Canvas對象
private List<PointF> pointFs;// 數據列表
private float[] rulerX, rulerY;// xy軸向刻度
private String signX, signY;// 設置X和Y坐標分別表示什么的文字
private float textY_X, textY_Y, textX_X, textX_Y;// 文字坐標
private float textSignSzie;// xy坐標標識文本字體大小
private float thickLineWidth, thinLineWidth;// 粗線和細線寬度
private float left, top, right, bottom;// 網格區域左上右下兩點坐標
private int viewSize;// 控件尺寸
private float maxX, maxY;// 橫縱軸向最大刻度
private float spaceX, spaceY;// 刻度間隔
public PolylineView(Context context, AttributeSet attrs) {
super(context, attrs);
// 實例化文本畫筆并設置參數
mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG | Paint.LINEAR_TEXT_FLAG);
mTextPaint.setColor(Color.WHITE);
// 實例化線條畫筆并設置參數
linePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
linePaint.setStyle(Paint.Style.STROKE);
linePaint.setColor(Color.WHITE);
// 實例化點畫筆并設置參數
pointPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
pointPaint.setStyle(Paint.Style.FILL);
pointPaint.setColor(Color.WHITE);
// 實例化Path對象
mPath = new Path();
// 實例化Canvas對象
mCanvas = new Canvas();
// 初始化數據
initData();
}
/**
* 初始化數據支撐
* View初始化時可以考慮給予一個模擬數據
* 當然我們可以通過setData方法設置自己的數據
*/
private void initData() {
Random random = new Random();
pointFs = new ArrayList<PointF>();
for (int i = 0; i < 20; i++) {
PointF pointF = new PointF();
pointF.x = (float) (random.nextInt(100) * i);
pointF.y = (float) (random.nextInt(100) * i);
pointFs.add(pointF);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 在我們沒學習測量控件之前強制寬高一致
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// 獲取控件尺寸
viewSize = w;
// 計算縱軸標識文本坐標
textY_X = viewSize * TIME_X;
textY_Y = viewSize * TIME_Y;
// 計算橫軸標識文本坐標
textX_X = viewSize * MONEY_X;
textX_Y = viewSize * MONEY_Y;
// 計算xy軸標識文本大小
textSignSzie = viewSize * TEXT_SIGN;
// 計算網格左上右下兩點坐標
left = viewSize * LEFT;
top = viewSize * TOP;
right = viewSize * RIGHT;
bottom = viewSize * BOTTOM;
// 計算粗線寬度
thickLineWidth = viewSize * THICK_LINE_WIDTH;
// 計算細線寬度
thinLineWidth = viewSize * THIN_LINE_WIDTH;
}
@Override
protected void onDraw(Canvas canvas) {
// 填充背景
canvas.drawColor(0xFF9596C4);
// 繪制標識元素
drawSign(canvas);
// 繪制網格
drawGrid(canvas);
// 繪制曲線
drawPolyline(canvas);
}
/**
* 繪制曲線
* 這里我使用一個新的Bitmap對象結合新的Canvas對象來繪制曲線
* 當然你可以直接在原來的canvas(onDraw傳來的那個)中直接繪制如果你還沒被坐標搞暈的話……
*
* @param canvas
* 畫布
*
*/
private void drawPolyline(Canvas canvas) {
// 生成一個Bitmap對象大小和我們的網格大小一致
mBitmap = Bitmap.createBitmap((int) (viewSize * (RIGHT - LEFT) - spaceX), (int) (viewSize * (BOTTOM - TOP) - spaceY), Bitmap.Config.ARGB_8888);
// 將Bitmap注入Canvas
mCanvas.setBitmap(mBitmap);
// 為畫布填充一個半透明的紅色
mCanvas.drawARGB(75, 255, 0, 0);
// 重置曲線
mPath.reset();
/*
* 生成Path和繪制Point
*/
for (int i = 0; i < pointFs.size(); i++) {
// 計算x坐標
float x = mCanvas.getWidth() / maxX * pointFs.get(i).x;
// 計算y坐標
float y = mCanvas.getHeight() / maxY * pointFs.get(i).y;
y = mCanvas.getHeight() - y;
// 繪制小點點
mCanvas.drawCircle(x, y, thickLineWidth, pointPaint);
/*
* 如果是第一個點則將其設置為Path的起點
*/
if (i == 0) {
mPath.moveTo(x, y);
}
// 連接各點
mPath.lineTo(x, y);
}
// 設置PathEffect
// linePaint.setPathEffect(new CornerPathEffect(200));
// 重置線條寬度
linePaint.setStrokeWidth(thickLineWidth);
// 將Path繪制到我們自定的Canvas上
mCanvas.drawPath(mPath, linePaint);
// 將mBitmap繪制到原來的canvas
canvas.drawBitmap(mBitmap, left, top + spaceY, null);
}
/**
* 繪制網格
*
* @param canvas
* 畫布
*/
private void drawGrid(Canvas canvas) {
// 鎖定畫布
canvas.save();
// 設置線條畫筆寬度
linePaint.setStrokeWidth(thickLineWidth);
// 計算xy軸Path
mPath.moveTo(left, top);
mPath.lineTo(left, bottom);
mPath.lineTo(right, bottom);
// 繪制xy軸
canvas.drawPath(mPath, linePaint);
// 繪制線條
drawLines(canvas);
// 釋放畫布
canvas.restore();
}
/**
* 繪制網格
*
* @param canvas
* 畫布
*/
private void drawLines(Canvas canvas) {
// 計算刻度文字尺寸
float textRulerSize = textSignSzie / 2F;
// 重置文字畫筆文字尺寸
mTextPaint.setTextSize(textRulerSize);
// 重置線條畫筆描邊寬度
linePaint.setStrokeWidth(thinLineWidth);
// 獲取數據長度
int count = pointFs.size();
// 計算除數的值為數據長度減一
int divisor = count - 1;
// 計算橫軸數據最大值
maxX = 0;
for (int i = 0; i < count; i++) {
if (maxX < pointFs.get(i).x) {
maxX = pointFs.get(i).x;
}
}
// 計算橫軸最近的能被count整除的值
int remainderX = ((int) maxX) % divisor;
maxX = remainderX == 0 ? ((int) maxX) : divisor - remainderX + ((int) maxX);
// 計算縱軸數據最大值
maxY = 0;
for (int i = 0; i < count; i++) {
if (maxY < pointFs.get(i).y) {
maxY = pointFs.get(i).y;
}
}
// 計算縱軸最近的能被count整除的值
int remainderY = ((int) maxY) % divisor;
maxY = remainderY == 0 ? ((int) maxY) : divisor - remainderY + ((int) maxY);
// 生成橫軸刻度值
rulerX = new float[count];
for (int i = 0; i < count; i++) {
rulerX[i] = maxX / divisor * i;
}
// 生成縱軸刻度值
rulerY = new float[count];
for (int i = 0; i < count; i++) {
rulerY[i] = maxY / divisor * i;
}
// 計算橫縱坐標刻度間隔
spaceY = viewSize * (BOTTOM - TOP) / count;
spaceX = viewSize * (RIGHT - LEFT) / count;
// 鎖定畫布并設置畫布透明度為75%
int sc = canvas.saveLayerAlpha(0, 0, canvas.getWidth(), canvas.getHeight(), 75, Canvas.ALL_SAVE_FLAG);
// 繪制橫縱線段
for (float y = viewSize * BOTTOM - spaceY; y > viewSize * TOP; y -= spaceY) {
for (float x = viewSize * LEFT; x < viewSize * RIGHT; x += spaceX) {
/*
* 繪制縱向線段
*/
if (y == viewSize * TOP + spaceY) {
canvas.drawLine(x, y, x, y + spaceY * (count - 1), linePaint);
}
/*
* 繪制橫向線段
*/
if (x == viewSize * RIGHT - spaceX) {
canvas.drawLine(x, y, x - spaceX * (count - 1), y, linePaint);
}
}
}
// 還原畫布
canvas.restoreToCount(sc);
// 繪制橫縱軸向刻度值
int index_x = 0, index_y = 1;
for (float y = viewSize * BOTTOM - spaceY; y > viewSize * TOP; y -= spaceY) {
for (float x = viewSize * LEFT; x < viewSize * RIGHT; x += spaceX) {
/*
* 繪制橫軸刻度數值
*/
if (y == viewSize * BOTTOM - spaceY) {
canvas.drawText(String.valueOf(rulerX[index_x]), x, y + textSignSzie + spaceY, mTextPaint);
}
/*
* 繪制縱軸刻度數值
*/
if (x == viewSize * LEFT) {
canvas.drawText(String.valueOf(rulerY[index_y]), x - thickLineWidth, y + textRulerSize, mTextPaint);
}
index_x++;
}
index_y++;
}
}
/**
* 繪制標識元素
*
* @param canvas
* 畫布
*/
private void drawSign(Canvas canvas) {
// 鎖定畫布
canvas.save();
// 設置文本畫筆文字尺寸
mTextPaint.setTextSize(textSignSzie);
// 繪制縱軸標識文字
mTextPaint.setTextAlign(Paint.Align.LEFT);
canvas.drawText(null == signY ? "y" : signY, textY_X, textY_Y, mTextPaint);
// 繪制橫軸標識文字
mTextPaint.setTextAlign(Paint.Align.RIGHT);
canvas.drawText(null == signX ? "x" : signX, textX_X, textX_Y, mTextPaint);
// 釋放畫布
canvas.restore();
}
/**
* 設置數據
*
* @param pointFs
* 點集合
*/
public synchronized void setData(List<PointF> pointFs, String signX, String signY) {
/*
* 數據為空直接GG
*/
if (null == pointFs || pointFs.size() == 0)
throw new IllegalArgumentException("No data to display !");
/*
* 控制數據長度不超過10個
* 對于折線圖來說數據太多就沒必要用折線圖表示了而是使用散點圖
*/
if (pointFs.size() > 10)
throw new IllegalArgumentException("The data is too long to display !");
// 設置數據并重繪視圖
this.pointFs = pointFs;
this.signX = signX;
this.signY = signY;
invalidate();
}
}
~~~
代碼純天然,我連封裝都沒有做什么 = = So、你可以從代碼中直接看到哥的思路~~如果沒有設置數據,我這里給了一個初始化的隨機數據,話說……隨機生成的數據畫出來的曲線挺帶感的:

如果你想得到我們設計圖的那種曲線,就需要自己去做特定的數據,實際應用中曲線的數據也肯定是特性的,比如天氣-時間曲線圖之類,這里的數據我們就直接在MainActivity中做:
~~~
public class MainActivity extends Activity {
private PolylineView mPolylineView;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mPolylineView = (PolylineView) findViewById(R.id.main_pv);
List<PointF> pointFs = new ArrayList<PointF>();
pointFs.add(new PointF(0.3F, 0.5F));
pointFs.add(new PointF(1F, 2.7F));
pointFs.add(new PointF(2F, 3.5F));
pointFs.add(new PointF(3F, 3.2F));
pointFs.add(new PointF(4F, 1.8F));
pointFs.add(new PointF(5F, 1.5F));
pointFs.add(new PointF(6F, 2.2F));
pointFs.add(new PointF(7F, 5.5F));
pointFs.add(new PointF(8F, 7F));
pointFs.add(new PointF(8.6F, 5.7F));
mPolylineView.setData(pointFs, "Money", "Time");
}
}
~~~
xml里面的代碼就不給了,運行效果如下:

大家發現得出的曲線很生硬,在1/4中我們曾講過PathEffect,可以應用到這里,如果大家還不知道PathEffect……可以去看我前面的文章。
自定義控件很重要的一個地方就是屏幕的適配,我們以控件的邊長作為基準參考可以避免很多的大小不一問題,上面的圖我都是在mx3上截取的,mx3分辨率高達1800*1080,我們可以換個手機測試下,以下是模擬器240*300分辨率上的樣子:

可以看到雖然刻度有點看不清了,但是整個控件的比例大小保持得很好。但是,如我所說,控件都是不完美的,如果能有完美的控件那就不需要我們自定義了,這個折線圖控件也一樣,首先它只能滿足特定的數據,而且風格就是這樣,如果我們把數據增多,比如20條數據:

可以看到軸上的刻度已經很緊湊了……這時我們可以考慮控制刻度的位數或使用科學記數法等等,但是……最有效的辦法還是控制數據長度……………………喲西!
簡單地介紹了Path之后回到我們的Canvas中,關于裁剪的方法
`clipPath(Path path) `
是不是變得透徹起來呢?
我們可以利用該方法從Canvas中“挖”取一塊不規則的畫布:
~~~
public class CanvasView extends View {
private Path mPath;
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
mPath = new Path();
mPath.moveTo(50, 50);
mPath.lineTo(75, 23);
mPath.lineTo(150, 100);
mPath.lineTo(80, 110);
mPath.close();
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE);
canvas.clipPath(mPath);
canvas.drawColor(Color.RED);
}
}
~~~

回顧Canvas中有關裁剪的方法,你會發現有一大堆帶有Region.Op參數的重載方法:
~~~
clipPath(Path path, Region.Op op)
clipRect(Rect rect, Region.Op op)
clipRect(RectF rect, Region.Op op)
clipRect(float left, float top, float right, float bottom, Region.Op op)
clipRegion(Region region, Region.Op op)
~~~
要明白這些方法的Region.Op參數那么首先要了解Region為何物。Region的意思是“區域”,在Android里呢它同樣表示的是一塊封閉的區域,Region中的方法都非常的簡單,我們重點來瞧瞧Region.Op,Op是Region的一個枚舉類,里面呢有六個枚舉常量:

那么Region.Op究竟有什么用呢?其實它就是個組合模式,在1/6中我們曾學過一個叫圖形混合模式的,而在本節開頭我們也曾講過Rect也有類似的組合方法,Region.Op灰常簡單,如果你看過1/6的圖形混合模式的話。這里我就給出一段測試代碼,大家可以嘗試去改變不同的組合模式看看效果
~~~
public class CanvasView extends View {
private Region mRegionA, mRegionB;// 區域A和區域B對象
private Paint mPaint;// 繪制邊框的Paint
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
// 實例化畫筆并設置屬性
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.WHITE);
mPaint.setStrokeWidth(2);
// 實例化區域A和區域B
mRegionA = new Region(100, 100, 300, 300);
mRegionB = new Region(200, 200, 400, 400);
}
@Override
protected void onDraw(Canvas canvas) {
// 填充顏色
canvas.drawColor(Color.BLUE);
canvas.save();
// 裁剪區域A
canvas.clipRegion(mRegionA);
// 再通過組合方式裁剪區域B
canvas.clipRegion(mRegionB, Region.Op.DIFFERENCE);
// 填充顏色
canvas.drawColor(Color.RED);
canvas.restore();
// 繪制框框幫助我們觀察
canvas.drawRect(100, 100, 300, 300, mPaint);
canvas.drawRect(200, 200, 400, 400, mPaint);
}
}
~~~
以下是各種組合模式的效果
DIFFERENCE

最終區域為第一個區域與第二個區域不同的區域。
INTERSECT

最終區域為第一個區域與第二個區域相交的區域。
REPLACE

最終區域為第二個區域。
REVERSE_DIFFERENCE

最終區域為第二個區域與第一個區域不同的區域。
UNION

最終區域為第一個區域加第二個區域。
XOR

最終區域為第一個區域加第二個區域并減去兩者相交的區域。
Region.Op就是這樣,它和我們之前講到的圖形混合模式幾乎一模一樣換湯不換藥……我在做示例的時候僅僅是使用了一個Region,實際上Rect、Cricle、Ovel等封閉的曲線都可以使用Region.Op,介于篇幅,而且也不難以理解就不多說了。
有些童鞋會問那么Region和Rect有什么區別呢?首先最重要的一點,Region表示的是一個區域,而Rect表示的是一個矩形,這是最根本的區別之一,其次,Region有個很特別的地方是它不受Canvas的變換影響,Canvas的local不會直接影響到Region自身,什么意思呢?我們來看一個simple你就會明白:
~~~
public class CanvasView extends View {
private Region mRegion;// 區域對象
private Rect mRect;// 矩形對象
private Paint mPaint;// 繪制邊框的Paint
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
// 實例化畫筆并設置屬性
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.DKGRAY);
mPaint.setStrokeWidth(2);
// 實例化矩形對象
mRect = new Rect(0, 0, 200, 200);
// 實例化區域對象
mRegion = new Region(200, 200, 400, 400);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.save();
// 裁剪矩形
canvas.clipRect(mRect);
canvas.drawColor(Color.RED);
canvas.restore();
canvas.save();
// 裁剪區域
canvas.clipRegion(mRegion);
canvas.drawColor(Color.RED);
canvas.restore();
// 為畫布繪制一個邊框便于觀察
canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), mPaint);
}
}
~~~
大家看到,我在[0, 0, 200, 200]和[200, 200, 400, 400]的位置分別繪制了Rect和Region,它們兩個所占大小是一樣的:

畫布因為和屏幕一樣大,so~~我們看不出描邊的效果,這時,我們將Canvas縮放至75%大小,看看會發生什么:
~~~
@Override
protected void onDraw(Canvas canvas) {
// 縮放畫布
canvas.scale(0.75F, 0.75F);
canvas.save();
// 裁剪矩形
canvas.clipRect(mRect);
canvas.drawColor(Color.RED);
canvas.restore();
canvas.save();
// 裁剪區域
canvas.clipRegion(mRegion);
canvas.drawColor(Color.RED);
canvas.restore();
// 為畫布繪制一個邊框便于觀察
canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), mPaint);
}
~~~
這時我們會看到,Rect隨著Canvas的縮放一起縮放了,但是Region依舊泰山不動地淡定:

呼呼呼……關于Canvas的一部分內容就先介紹到此,Canvas的內容比我想象的還要多啊啊啊啊啊啊啊!!!!!主要是Canvas涉及不少的擦邊球類一寫根本停不下來,媽蛋!!!!
下一節爭取Over掉Canvas的內容,7/12進入測量的學習,好吧,就這樣吧,我也是醉了……%¥#%¥#%
源碼下載:[傳送門](http://download.csdn.net/detail/aigestudio/8268903)