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();