雕蟲曉技(三) 通用圓角布局全解析

1. 前言

之前,我在 GitHub 分享了一個開源庫:

GcsSloop/rclayoutgithub.com圖標

,這個庫的主要目的是快速實現 Android中 的圓角需求,例如這樣的效果。

分享這個庫的時候只是覺得可能有用而已,但沒有想到居然有了 800 多個 Star,看來有不少人像我一樣,對圓角這一需求比較苦惱。

圓角算是一種比較常見的需求了,最常應用於圖片,因此可以找到大量的自定義圓角 ImageView,不僅如此,一些比較流行的圖片載入框架也都對圓角進行了支持,像 Fresco 和 Glide 很容易就能實現圓角效果。

除了圖片之外,另一種比較常見的就是圓角背景了,例如 TextView 的背景或者 Button 的背景,針對於背景的圓角也很容易實現,一般用圖片或者寫一個 shape 就行了,例如:

<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <solid android:color="#66CCFF" /> <corners android:topLeftRadius="6dp" android:topRightRadius="6dp" android:bottomRightRadius="6dp" android:bottomLeftRadius="6dp"/> </shape>

上面的這些都是很容易實現的常見需求,但也有一種稍微特殊的常見需求,像下面這樣:

由多個控制項組合而成的條目,包括左上角一個角標、文字、文字背景和圖片。

從上圖可以看出啦,圖片已經是圓角了,可是角標和文字背景依舊是方的,這顯然不是我們想要的效果。

我之前對這種組合控制項的處理方案是這樣的:

角標:讓設計重新切成帶圓角的圖,或者用支持圓角的ImageView進行展示。

文字背景:做一張帶圓角的圖,或者寫一個底部是圓角的 shape。

這樣展示是沒有問題的,唯一比較麻煩的是改需求的時候,如果需要調整圓角的大小,需要分別調整三個地方:圖片的圓角、角標的圓角、以及文字背景的圓角。如果一個地方忘記了調整,那麼就會出現圓角無法對齊的情況。

正因如此,才想到開發一個圓角布局,將它們包裹起來,這樣在不僅省去了處理角標和文字背景的麻煩,在修改需求的時候也只用修改一個地方就可以了。

2. 圓角布局原理

所謂圓角布局,本質上還是一個方塊,只不過是讓圓角之外的部分不顯示而已,簡單來說就是對畫布進行裁剪,也可以理解為設置一個遮罩,讓目標區域外的內容不顯示。

2.1 clipPath

在最初設計的時候,使用了 canvas 中 clipPath 的方法,該方法實現起來簡單快速,原理如下:

2.1.1 構造一個帶圓角的Path

初始化圓角信息和Path:

// 1. 定義圓角信息 和 pathprivate float[] radii = new float[8]; // top-left, top-right, bottom-right, bottom-leftprivate Path mPath;// 2. 通過自定義屬性獲取圓角信息(以左上角為例)TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RCRelativeLayout);mRoundCorner = ta.getDimensionPixelSize(R.styleable.RCRelativeLayout_round_corner, 0);radii[0] = mRoundCornerTopLeft;radii[1] = mRoundCornerTopLeft;// 3. 創建空的PathmPath = new Path();

在 onSizeChanged 方法中根據 View 大小創建圓角 Path,在這裡要注意對 padding 值的處理。

@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mRectF.left = getPaddingLeft(); mRectF.top = getPaddingTop(); mRectF.right = w - getPaddingRight(); mRectF.bottom = h - getPaddingBottom(); mPath.addRoundRect(mRectF, radii, Path.Direction.CW);}

2.1.2 剪裁畫布

直接使用 canvas 的 clipPath 方法進行剪裁。

@Overrideprotected boolean drawChild(Canvas canvas, View child, long drawingTime) { // 繪製圓角 canvas.clipPath(mPath); return super.drawChild(canvas, child, drawingTime);}

這樣一個圓角布局就實現了。

2.2 setXfermode

由於 clipPath 方法不支持抗鋸齒,因此在一些解析度較低的設備上邊緣看起來非常粗糙,所以換了一種實現方案,利用畫筆的模式來實現對內容區域的剪裁。

有關於畫筆模式可以參考:gcssloop.com/customview

2.2.1 Paint

創建畫筆並設置其模式:

private Paint mPaint; // 畫筆 mPaint = new Paint();mPaint.setColor(Color.WHITE);mPaint.setAntiAlias(true);// 繪製模式為填充mPaint.setStyle(Paint.Style.FILL);// 混合模式為 DST_IN, 即僅顯示當前繪製區域和背景區域交集的部分,並僅顯示背景內容。mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));

2.2.2 繪製

創建帶有圓角 Path 的內容不變,只不過這次將繪製部分移動到了 dispatchDraw 裡面,其實放在 drawChild 裡面也是可以的,只要注意一下繪製順序即可。

由於畫筆模式是 SDT_IN,所以會顯示原有內容區域和圓角Path區域交集的部分,並僅顯示原有內容

@Override protected void dispatchDraw(Canvas canvas) { canvas.saveLayer(new RectF(0, 0, canvas.getWidth(), canvas.getHeight()), null, Canvas .ALL_SAVE_FLAG); // 繪製子控制項 super.dispatchDraw(canvas); // 繪製帶有圓角的 Path canvas.drawPath(mPath, mPaint); canvas.restore();}

2.3 支持圓形

知道了繪製原理想要支持圓形就很簡單了,其實就是將帶有圓角矩形的 Path 更換為帶有圓形的 Path 即可。

為了支持圓形,我定義了一個 roundAsCircle 屬性,只要檢測到這個屬性,就在 Path 中添加一個圓形,否則添加一個圓角矩形。

在支持圓形的時候有一個坑需要注意一下,就是控制項長寬比不一致的情況下,由於是按照最短的邊計算的,所以在長寬比不一致的情況下,直接向 Path 添加圓形, Path 是無法填充滿畫布的,在繪製的時候可能會出現圓形之外依舊有內容被繪製出來,所以這裡使用了兩個 moveTo 操作來讓 Path 填充滿畫布(下文代碼中注釋部分)。

@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); RectF areas = new RectF(); areas.left = getPaddingLeft(); areas.top = getPaddingTop(); areas.right = w - getPaddingRight(); areas.bottom = h - getPaddingBottom(); mClipPath.reset(); if (mRoundAsCircle) { float d = areas.width() >= areas.height() ? areas.height() : areas.width(); float r = d / 2; PointF center = new PointF(w / 2, h / 2); mClipPath.addCircle(center.x, center.y, r, Path.Direction.CW); mClipPath.moveTo(0, 0); // 通過空操作讓Path區域佔滿畫布 mClipPath.moveTo(w, h); } else { mClipPath.addRoundRect(areas, radii, Path.Direction.CW); } Region clip = new Region((int) areas.left, (int) areas.top, (int) areas.right, (int) areas.bottom); mAreaRegion.setPath(mClipPath, clip);}

2.4 支持描邊

支持描邊就更簡單了,就是基礎的繪製操作,如下:

@Override protected void dispatchDraw(Canvas canvas) { canvas.saveLayer(new RectF(0, 0, canvas.getWidth(), canvas.getHeight()), null, Canvas .ALL_SAVE_FLAG); super.dispatchDraw(canvas); // 描邊 if (mStrokeWidth > 0) { mPaint.setXfermode(null); mPaint.setStrokeWidth(mStrokeWidth * 2); mPaint.setColor(mStrokeColor); mPaint.setStyle(Paint.Style.STROKE); canvas.drawPath(mStrokePath, mPaint); } // 剪裁 mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN)); mPaint.setStrokeWidth(0); mPaint.setStyle(Paint.Style.FILL); canvas.drawPath(mClipPath, mPaint); canvas.restore();}

2.5 限定點擊區域

上面雖然實現了的圓角、圓形的顯示,但是沒有被繪製出來的部分依舊是可以被點擊的,因此需要限定點擊區域,對超出顯示區域的部分不進行響應。

這個原理也不複雜,主要是利用事件分發機制和 Region 的 contains 方法。

2.5.1 獲取可點擊區域

// 定義 Region,即內容區域private Region mAreaRegion;mAreaRegion = new Region();// 根據內容區域 Path 創建 Region,clip 為整個畫布大小Region clip = new Region((int) areas.left, (int) areas.top, (int) areas.right, (int) areas.bottom);mAreaRegion.setPath(mStrokePath, clip);

2.5.2 對超出區域外的事件不處理

使用 contains 方法判斷事件的位置是否在區域內,如果不在區域內,就直接返回 false,表示不處理。

@Override public boolean dispatchTouchEvent(MotionEvent ev) { if (!mAreaRegion.contains((int) ev.getX(), (int) ev.getY())) { return false; } return super.dispatchTouchEvent(ev);}

到此為止,有關通用圓角布局的核心內容就結束了。

3. 結語

通用圓角布局的原理並不複雜,而且代碼實現起來也非常簡單,感興趣的可以在 Github 上看一下源碼,核心代碼不過 100 行左右。

RCLayout: github.com/GcsSloop/rcl

關於作者

GcsSloop,一名 2.5 次元魔法師。

搜索關注公眾號「GcsSloop」獲取最新文章推送。


推薦閱讀:

出色地利用了手機感測器的優秀應用都有哪些?
如何評價 Moto X 手機?
努比亞高配星空灰 華為榮耀v9和一加3T高配槍灰色這3款手機選哪一款?

TAG:Android开发 | Android | 开源项目 |