1. 圖像扭曲
Canvas中提供了一個drawBitmapMesh方法,通過該方法可以實現位圖的扭曲效果,下面來分析一下這個方法:
方法簽名如下:
public void drawBitmapMesh(@NonNull Bitmap bitmap, int meshWidth, int meshHeight,
@NonNull float[] verts, int vertOffset, @Nullable int[] colors, int colorOffset,
@Nullable Paint paint)
1> 該方法將bitmap橫向、縱向分別均勻切割成meshWidth、meshHeight份,這樣的化bitmap就被切割成網格狀,
如圖1所示;
2> 網格交叉點坐標有(meshWidth + 1)*(meshHeight + 1)個,verts數組就是用來保存網格交叉點的坐標,
vertOffset表示verts數組從第幾個元素開始保存網格交叉點的坐標,因此verts數組的長度至少為
(meshWidth + 1)*(meshHeight + 1)*2+ vertOffset;
3> colors用于保存為網格的交叉點指定的顏色,該顏色會和位圖中對應的顏色進行multiplied
(multiplied可以參考圖2),colorOffset表示colors數組從第幾個元素開始保存為網格的交叉點指定的顏色,
因此colors數組的長度至少為(meshWidth + 1)*(meshHeight + 1)+ colorOffset,colors可以為null;
4> paint表示用于繪制bitmap的畫筆,可以為null。
注意:該方法在API的級別大于等于18時才支持硬件加速。
實現水波紋效果:
首先通過俯視的視角看一下水波紋效果:
上圖中繪制了一個波長的水波紋,波峰到波源的距離是波的半徑,波長為相鄰波谷/波峰之間的距離;為了讓圖片有波動的感覺,在水波紋的范圍內(上圖中的藍色區域),以波峰為分界線,內側的點向內偏移,外側的點向外偏移,再通過水平視角看一下水波紋的效果:
由上圖可知離波峰越近的頂點,偏移的距離會越大,反之越小;那么可以利用余弦函數來計算偏移的距離。
先來秀一下最后實現的效果:
實現步驟:
1> 自定義繼承自View的RippleView,參數初始化:
// 實現水波紋效果的位圖
private Bitmap meshBitmap = null;
// 網格的行數
private static final int MESH_WIGHT = 20;
// 網格的列數
private static final int MESH_HEIGHT = 20;
// 網格的格數
private static final int MESH_COUNT = (MESH_WIGHT + 1) * (MESH_HEIGHT + 1);
// 保存網格交叉點的原始坐標
private final float[] originVerts = new float[MESH_COUNT * 2];
// 保存網格交叉點變換后的坐標
private final float[] targetVerts = new float[MESH_COUNT * 2];
//水波寬度的一半
private float rippleWidth = 100f;
//水波擴散速度
private float rippleSpeed = 15f;
//水波半徑
private float rippleRadius;
//水波動畫是否執行中
private boolean isRippling = false;
public RippleView(Context context) {
super(context);
initData(context, null);
}
public RippleView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public RippleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initData(context, attrs);
}
private void initData(Context context, @Nullable AttributeSet attrs) {
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RippleView);
Drawable drawable = ta.getDrawable(R.styleable.RippleView_ripple_view_image);
if (null == drawable || !(drawable instanceof BitmapDrawable)) {
throw new IllegalArgumentException("ripple_view_image only support images!");
}
meshBitmap = ((BitmapDrawable) drawable).getBitmap();
ta.recycle();
int width = meshBitmap.getWidth();
int height = meshBitmap.getHeight();
int index = 0;
for (int row = 0; row <= MESH_HEIGHT; row++) {
float y = height * row / MESH_HEIGHT;
for (int col = 0; col <= MESH_WIGHT; col++) {
float x = width * col / MESH_WIGHT;
originVerts[index * 2] = targetVerts[index * 2] = x;
originVerts[index * 2 + 1] = targetVerts[index * 2 + 1] = y;
index++;
}
}
}
上面的注釋應該很清晰了,我就不再贅敘了。
2> 當手指觸碰位圖時,onTouchEvent方法就會被回調:
@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = MotionEventCompat.getActionMasked(event);
switch (action) {
case MotionEvent.ACTION_DOWN: {
showRipple(event.getX(), event.getY());
break;
}
}
return true;
}
private void showRipple(final float touchPointX, final float touchPointY) {
if (isRippling) {
return;
}
//根據水波擴散速度和位圖對角線距離計算出刷新次數,確保水波紋完全消失
int viewLength = (int) getLength(meshBitmap.getWidth(), meshBitmap.getHeight());
final int count = (int) ((viewLength + rippleWidth * 2) / rippleSpeed);
final ValueAnimator valueAnimator = ValueAnimator.ofInt(1, count);
valueAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
super.onAnimationStart(animation);
isRippling = true;
}
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
isRippling = false;
valueAnimator.removeAllUpdateListeners();
valueAnimator.removeAllListeners();
}
});
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int animatorValue = (int) animation.getAnimatedValue();
rippleRadius = animatorValue * rippleSpeed;
warp(touchPointX, touchPointY);
}
});
valueAnimator.setDuration(count * 10);
valueAnimator.start();
}
/**
* 根據寬高,獲取對角線距離
*
* @param width 寬
* @param height 高
* @return 距離
*/
private float getLength(float width, float height) {
return (float) Math.sqrt(width * width + height * height);
}
從上面的代碼可知當點擊位圖時,showRipple方法會被調用,該方法主要做了兩件事情:
<1> 位了實現水波紋逐漸擴散直到完全消失的效果,首先就要計算出刷新次數:
如果在極限情況下(位圖的四個頂點作為點擊點)水波紋也能完全消失,那就達到了理想的效果,上圖就是模擬極限情況下繪制的效果圖,中間最小的的圓形區域就是水波紋最開始的狀態,最外側的圓環區域就是水波紋結束的狀態,因此刷新的次數為:
// 獲取位圖的對角線長度
int viewLength = (int) getLength(meshBitmap.getWidth(), meshBitmap.getHeight());
// 對角線長度加上一個波長就是水波紋的移動距離,然后除以波速就等到了刷新的次數
final int count = (int) ((viewLength + rippleWidth * 2) / rippleSpeed);
<2> 通過動畫實現刷新,warp方法被調用。
3> warp方法源碼如下:
/**
* 計算圖片變換后的網格交叉點的坐標
*
* @param touchPointX 觸摸點 x 坐標
* @param touchPointY 觸摸點 y 坐標
*/
private void warp(float touchPointX, float touchPointY) {
for (int i = 0; i < MESH_COUNT * 2; i += 2) {
float originVertX = originVerts[i];
float originVertY = originVerts[i + 1];
float length = getLength(originVertX - touchPointX, originVertY - touchPointY);
// 判斷網格交叉點是否在水波紋區域
if (length > rippleRadius - rippleWidth && length < rippleRadius + rippleWidth) {
PointF point = getRipplePoint(touchPointX, touchPointY, originVertX, originVertY);
targetVerts[i] = point.x;
targetVerts[i + 1] = point.y;
} else {
targetVerts[i] = originVerts[i];
targetVerts[i + 1] = originVerts[i + 1];
}
}
invalidate();
}
warp方法遍歷所有的網格交叉點,然后判斷網格交叉點是否在水波紋區域,如果在水波紋區域,就會通過getRipplePoint方法獲取到網格交叉點偏移后的坐標,否則不做任何處理。getRipplePoint方法的源碼如下:
/**
* 獲取網格交叉點的偏移坐標
*
* @param touchPointX 觸摸點 x 坐標
* @param touchPointY 觸摸點 y 坐標
* @param originVertX 待偏移頂點的原 x 坐標
* @param originVertY 待偏移頂點的原 y 坐標
* @return 偏移后坐標
*/
private PointF getRipplePoint(float touchPointX, float touchPointY, float originVertX, float originVertY) {
float length = getLength(originVertX - touchPointX, originVertY - touchPointY);
//偏移點與觸摸點間的角度
float angle = (float) Math.atan(Math.abs((originVertY - touchPointY) / (originVertX - touchPointX)));
//通過余弦函數計算直線偏移距離,這樣的話水波紋會更加生動
float rate = (length - rippleRadius) / rippleWidth;
float offset = (float) Math.cos(rate) * 10f;
//計算在橫向和縱向上的偏移距離
float offsetX = offset * (float) Math.cos(angle);
float offsetY = offset * (float) Math.sin(angle);
//偏移后的坐標
float targetX;
float targetY;
if (length < rippleRadius + rippleWidth && length > rippleRadius) {
//波峰外的偏移坐標
if (originVertX > touchPointX) {
targetX = originVertX + offsetX;
} else {
targetX = originVertX - offsetX;
}
if (originVertY > touchPointY) {
targetY = originVertY + offsetY;
} else {
targetY = originVertY - offsetY;
}
} else {
//波峰內的偏移坐標
if (originVertX > touchPointX) {
targetX = originVertX - offsetX;
} else {
targetX = originVertX + offsetX;
}
if (originVertY > touchPointY) {
targetY = originVertY - offsetY;
} else {
targetY = originVertY + offsetY;
}
}
return new PointF(targetX, targetY);
}
getRipplePoint方法主要做了兩件事情:
<1> 通過觸碰點的坐標和網格交叉點的坐標計算出網格交叉點在橫向和縱向的偏移距離,下圖是處于波峰外側的網格交叉點計算偏移距離的過程圖:
結合上圖,代碼中計算網格交叉點的在橫向和縱向的偏移距離應該就很容易理解了。
<2> 得到了網格交叉點在橫行和縱向的偏移距離后,然后根據在波峰內側還是外側來計算偏移后的坐標。
warp方法得到getRipplePoint方法返回的偏移后的坐標,保存到targetVerts數組中,接下來就是刷新界面,onDraw方法被調用:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmapMesh(meshBitmap, MESH_WIGHT, MESH_HEIGHT, targetVerts, 0, null, 0, null);
}
warp會被動畫執行很多次,直到水波紋完全消失,從而實現了波動的效果。
2. 繪制文本
我們在自定義View中有的時候會想自己繪制文字,自己繪制文字的時候,我們通常希望把文字精確定位,文字居中(水平、垂直)是普遍的需求,所以這里就以文字居中為例。Android是通過Canvas中的drawText方法進行文字繪制的,方法使用說明如下:
public void drawText(@NonNull String text, int start, int end, float x, float y, @NonNull Paint paint) {
text:要繪制的字符串
start:第一個要繪制字符的下標值
end:最后一個要繪制字符的下標值
x默認是字符串的左邊在屏幕的位置,如果設置了paint.setTextAlign(Paint.Align.CENTER);那就是字符串的中心對應的x坐標,y是指定字符串baseline在屏幕上的位置。
Canvas繪制文本時,通過Paint對象獲取FontMetrics對象,然后利用FontMetrics對象計算baseline在屏幕上的位置。 它的思路和java.awt.FontMetrics的基本相同。 FontMetrics對象它以四個基本坐標為基準,如下圖所示:
FontMetrics.top 該距離是從所繪字符的baseline之上至可繪制區域的最高點。
FontMetrics.ascent 該距離是從所繪字符的baseline之上至該字符所繪制的最高點。這個距離是系統推薦。
FontMetrics.descent 該距離是從所繪字符的baseline之下至該字符所繪制的最低點。這個距離是系統推薦的。
FontMetrics.bottom 該距離是從所繪字符的baseline之下至可繪制區域的最低點。
由上圖可以知道字符串的繪制區域在FontMetrics.ascent和FontMetrics.descent之間,因此讓字符串垂直居中顯示就相當于讓字符串的繪制區域垂直居中顯示;由于drawText方法中y參數所需要的值就是圖中的紅線(baseline)對應的y值,因此只要計算出字符串的繪制區域垂直居中顯示時紅線(baseline)對應的y值即可,計算過程如下:
假設我們所求的baseline的值為baseY;
text的descent距離:
①descentY = baseY + fontMetrics.descent;
text的字體高度:
②fontHeight = fontMetrics.descent- fontMetrics.ascent
因為我們要讓text垂直居中,所以此時text的bottom距離應該為:
③descentY=1/2 * height + 1/2 * fontHeight
所以由上述①②③公式就可以推得:
baseY = 1/2 * height - 1/2 * (fontMetrics.ascent + fontMetrics.descent)
此時求得baseline的值,即cavans.drawText()里的y的坐標。
獲取fontMetrics的方法如下:
mFontMetricsInt = mPaint.getFontMetricsInt();