Canvas常用方法解析第一篇

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

图2

实现水波纹效果:
首先通过俯视的视角看一下水波纹效果:

水波纹俯视图

上图中绘制了一个波长的水波纹,波峰到波源的距离是波的半径,波长为相邻波谷/波峰之间的距离;为了让图片有波动的感觉,在水波纹的范围内(上图中的蓝色区域),以波峰为分界线,内侧的点向内偏移,外侧的点向外偏移,再通过水平视角看一下水波纹的效果:
水平视角的水波纹

由上图可知离波峰越近的顶点,偏移的距离会越大,反之越小;那么可以利用余弦函数来计算偏移的距离。

先来秀一下最后实现的效果:



实现步骤:
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();
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容