BitmapShader实例——Titanic for Android

简介

本文以一个优秀的开源项目——Titanic来探讨BitmapShader的实用技巧。
titanic

本文并不全全分析其源码,而是在对其源码的理解下,循序渐进的实现一个更易懂的Demo。

基本波浪实现

很明显,源工程是在TextView的基础上加上了一个横竖动画的波浪。
这里,我们先看看,基本的波浪怎么实现。
查看源工程的资源文件,我们发现了一张重要的图——wave.png,如下。
wave
啥?啥也看不到?下载下来吧,谁让这张图是由白色和透明色组成的呢。
注意到这张图好像是很久很久以前学过的正弦曲线,而且正好是一个完整的周期
正因为如此,如果这张图在水平方向无限重复,就组成了一个波浪!
水平方向无限重复,又似曾相识。
回顾一下Shader.TileMode:

  • CLAMP的作用是如果渲染器超出原始边界范围,则会复制边缘颜色对超出范围的区域进行着色。
  • REPEAT的作用是在横向和纵向上以平铺的形式重复渲染位图。
  • MIRROR的作用是在横向和纵向上以镜像的方式重复渲染位图。

OK,看下面一段代码:

1
2
3
4
// 加载波浪图片
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.wave);
// 初始化BitmapShader,以波浪图片为底,水平重复,垂直颜色延伸
mBitmapShader = new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);

这段代码就初始化了一个完整的波浪BitmapShader,如下图:
wave_static
OK,那现在只要让这个波浪动起来就行了,我们先只实现水平方向的移动。
实现动画,就是不停的让波浪右移(当然,你乐意也可以左移)。
我们参考源码,发现在onDraw()方法里面,可以这么写:

1
2
3
4
5
6
7
8
9
10
@Override
public void onDraw(Canvas canvas) {
// 根据mXMove的值左移
mShaderMatrix.setTranslate(mXMove, 0);
mBitmapShader.setLocalMatrix(mShaderMatrix);

canvas.drawRect(0, 0, getWidth(), getHeight(), mBitmapPaint);

super.onDraw(canvas);
}

因为我们是通过将BitmapShader赋给一个Bitmap画笔(Paint)来实现波浪绘制的,所以,我们在界面刷新的时候,通过Matrix将我们的BitmapShader右移一段距离,就能实现波浪的右移。
动画的实现,源工程通过一个独立的控制类Titanic来实现,我觉得麻烦,直接将动画写到了View里面。这种动画,通过ObjectAnimation实现,去不停的改变水平的移动距离——mXMove,动画的监听事件当然就是invalidate(),这样,我们的整个画面就动起来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void startAnimation() {
// 通过线性改变mXMove值来实现动画
mXAnimator = ObjectAnimator.ofInt(this, "mXMove", 0, mWaveBitmapWidth);
mXAnimator.setRepeatCount(ValueAnimator.INFINITE);
// 必须加上线性均匀属性值变化方式,否则动画会有卡顿
mXAnimator.setInterpolator(new LinearInterpolator());
mXAnimator.setDuration(1000);
mXAnimator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animatior) {
invalidate();
}
});
mXAnimator.start();
}

大功告成,我们的波浪终于动起来了:
wave_dynamic
这里,附上我写的波浪Demo的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package com.double0291;

import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.LinearInterpolator;

import com.double0291.R;

public class WaveView extends View {
private Paint mBitmapPaint;
private BitmapShader mBitmapShader;
private Matrix mShaderMatrix;

// 水平方向动画
private ObjectAnimator mXAnimator;

private int mWaveBitmapWidth;
private int mXMove;

public WaveView(Context context) {
super(context);
init();
}

public WaveView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public WaveView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}

@Override
public void onDraw(Canvas canvas) {
// 根据mXMove的值左移
mShaderMatrix.setTranslate(mXMove, 0);
mBitmapShader.setLocalMatrix(mShaderMatrix);

canvas.drawRect(0, 0, getWidth(), getHeight(), mBitmapPaint);

super.onDraw(canvas);
}

@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
startAnimation();
}

@Override
protected void onDetachedFromWindow() {
stopAnimation();
super.onDetachedFromWindow();
}

/**
* 这个set方法必须,否则ObjectAnimator.ofInt(...)方法会失效
*/

public void setMXMove(int xMove) {
this.mXMove = xMove;
}

private void init() {
// 加载波浪图片
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.wave);
// 获取波浪图片的宽,要动态获取,因为在不同分辨率手机下或者不同Drawable文件夹下的宽度不同
mWaveBitmapWidth = bitmap.getWidth();
// 初始化BitmapShader,以波浪图片为底,水平重复,垂直颜色延伸
mBitmapShader = new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
// 以BitmapShader为笔触初始化画笔Paint
mBitmapPaint = new Paint();
mBitmapPaint.setAntiAlias(true); // 去锯齿
mBitmapPaint.setShader(mBitmapShader);
// 新建一个矩阵Matrix,onDraw()的时候会用到
mShaderMatrix = new Matrix();
}

private void startAnimation() {
// 通过线性改变mXMove值来实现动画
mXAnimator = ObjectAnimator.ofInt(this, "mXMove", 0, mWaveBitmapWidth);
mXAnimator.setRepeatCount(ValueAnimator.INFINITE);
// 必须加上线性均匀属性值变化方式,否则动画会有卡顿
mXAnimator.setInterpolator(new LinearInterpolator());
mXAnimator.setDuration(1000);
mXAnimator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animatior) {
// 刷新UI
invalidate();
}
});
mXAnimator.start();
}

private void stopAnimation() {
if (mXAnimator != null) {
mXAnimator.cancel();
mXAnimator = null;
}
}
}

代码超级短,却精巧无比,但是,这里面也有非常重要的注意点:

  1. 我们通过ObjectAnimator.ofInt(…)来实现私有变量mXMove的动态改变,类中一定要有mXMove的set方法,否则动画就失效了,ofInt()方法就是通过类的set方法去改变变量的值的。
  2. 动画通过不停的重复水平平移动作来实现,每一次水平的平移距离应该是我们原始的wave.png的宽度(不知道为什么的话就要补一下高中数学知识了)。源工程的移动距离直接写死了200,因为这张图的水平像素就是200。但实际上,不同的Android手机下,甚至将图放到不同的Drawable文件夹下,真实的像素是不同的,所以,要在init()方法里面,通过bitmap.getWidth()来获取这张图真实的水平像素。
  3. 动画一定要添加线性均匀属性值变化的方式——LinearInterpolator(),否则,会出现卡顿。因为不线性的话,图片移动到末尾时重新回到开头就无法做到无缝连接,就会跳帧。
  4. onAttachedToWindow()onDetachedFromWindow()中控制动画的启动结束,比较原始。也可以通过提供接口来实现动画的启动和结束。

TitanicTextView

TitanicTextView就是在我们上面的波浪基础上,再加上垂直的动画。
垂直动画我们也通过ObjectAnimator.ofInt(…)来实现。要实现波浪从字体底部动画移动到顶部,我们将mYMove的范围定在了下面这个范围:

1
mYAnimator = ObjectAnimator.ofInt(this, "mYMove", getHeight() / 2, -getHeight() / 2);

同时注意,在onDraw()绘制的时候,我们要增加一个偏移,因为我们的wave.png的上下部分是对称的,所以,我们的位移就可以定为

1
(getHeight() - mWaveBitmapHeight) / 2

至于为什么要这个位移,可以通过更改这个位移的值得到答案。
TitanicTextView的精华在于下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
// wave.png的上半部分是透明的,要新建一个Canvas,将上半部分画成字体颜色
mBackgroundBitmap = Bitmap.createBitmap(mWaveBitmapWidth, mWaveBitmapHeight, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(mBackgroundBitmap);
canvas.drawColor(getCurrentTextColor());
mWave.setBounds(0, 0, mWaveBitmapWidth, mWaveBitmapHeight);
mWave.draw(canvas);

// 初始化BitmapShader,以波浪图片为底,水平重复,垂直颜色延伸
mBitmapShader = new BitmapShader(mBackgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
// 将BitmapShader设置为TextView的笔触
getPaint().setShader(mBitmapShader);

上面说过,wave.png的上半部分是透明的,下半部分是白色的波浪部分,这是基础。
这里,先构造了一个跟wave.png一样大小的Bitmap,然后用这个Bitmap创建了一个Canvas对象,将这个Canvas画上了TextView的文字颜色,作为背景。
这个时候,我们在给我们的Drawable设上一个Bounds,用这个Drawable将Canvas来draw一下,这个时候,我们的Bitmap,就变成了和wave.png一样大小,下半部分是白色波浪,上半部分是文字颜色背景的一张图片了。
以这个Bitmap创建一个BitmapShader,这个时候,我们的BitmapShader就变成了这样了。
wave_has_bg
最后我们通过getPaint().setShader()方法将我们的Bitmap设置为绘制TextView的笔触,这个时候的TextView就带上了波浪背景了。
具体我这边写的Demo的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
package com.double0291;

import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.animation.LinearInterpolator;
import android.widget.TextView;

public class TitanicTextView extends TextView {
private BitmapShader mBitmapShader;
// onDraw()的时候会用到
private Matrix mShaderMatrix = new Matrix();

// wave.png图片,上半部分透明
private Drawable mWave;
// wave.png图片的长宽
private int mWaveBitmapWidth, mWaveBitmapHeight;
// 将wave.png图片上半部分画成字体颜色,然后做笔刷,实现波浪字体效果
private Bitmap mBackgroundBitmap;

// 水平方向动画
private ObjectAnimator mXAnimator;
// 垂直方向动画
private ObjectAnimator mYAnimator;
// 动画集
private AnimatorSet mAnimatorSet;
// 动画过程的水平垂直方向移动距离
private int mXMove, mYMove;
// 是否已经启动动画
boolean mIsAnimating;

public TitanicTextView(Context context) {
super(context);
}

public TitanicTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}

public TitanicTextView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}

@Override
public void onDraw(Canvas canvas) {
// 根据mXMove, mYMove的值移动
mShaderMatrix.setTranslate(mXMove, mYMove + ((getHeight() - mWaveBitmapHeight) / 2));
mBitmapShader.setLocalMatrix(mShaderMatrix);

super.onDraw(canvas);

// 垂直方向的动画范围要根据整个View的Height来定,但onAttachedToWindow的时候,Height还没有确定,getHeight()值为0,
// 所以,动画在onDraw()中启动
if (!mIsAnimating) {
startAnimation();
}
}

@Override
protected void onDetachedFromWindow() {
stopAnimation();
super.onDetachedFromWindow();
}

@Override
public void setTextColor(int color) {
super.setTextColor(color);
// 重置字体颜色的时候需要重置笔刷
setBitmapShader();
}

@Override
public void setTextColor(ColorStateList colors) {
super.setTextColor(colors);
// 重置字体颜色的时候需要重置笔刷
setBitmapShader();
}

/**
* 这个set方法必须,否则ObjectAnimator.ofInt(...)方法会失效
*/

public void setMXMove(int xMove) {
this.mXMove = xMove;
// 动画动态改变mXMove的值后要刷新UI
invalidate();
}

/**
* 这个set方法必须,否则ObjectAnimator.ofInt(...)方法会失效
*/

public void setMYMove(int yMove) {
this.mYMove = yMove;
// 动画动态改变mYMove的值后要刷新UI
invalidate();
}

private void setBitmapShader() {
// 加载波浪图片
if (mWave == null)
mWave = getResources().getDrawable(R.drawable.wave);

// 获取波浪图片的宽高,要动态获取,因为在不同分辨率手机下或者不同Drawable文件夹下的宽度不同
mWaveBitmapWidth = mWave.getIntrinsicWidth();
mWaveBitmapHeight = mWave.getIntrinsicHeight();

// 做一下recycle,防OOM
if (mBackgroundBitmap != null && !mBackgroundBitmap.isRecycled()) {
mBackgroundBitmap.recycle();
mBackgroundBitmap = null;
}

// wave.png的上半部分是透明的,要新建一个Canvas,将上半部分画成字体颜色
mBackgroundBitmap = Bitmap.createBitmap(mWaveBitmapWidth, mWaveBitmapHeight, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(mBackgroundBitmap);
canvas.drawColor(getCurrentTextColor());
mWave.setBounds(0, 0, mWaveBitmapWidth, mWaveBitmapHeight);
mWave.draw(canvas);

// 初始化BitmapShader,以波浪图片为底,水平重复,垂直颜色延伸
mBitmapShader = new BitmapShader(mBackgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
// 将BitmapShader设置为TextView的笔触
getPaint().setShader(mBitmapShader);
}

private void startAnimation() {
// 通过线性改变mXMove值来实现水平方向无限循环
mXAnimator = ObjectAnimator.ofInt(this, "mXMove", 0, mWaveBitmapWidth);
mXAnimator.setRepeatCount(ValueAnimator.INFINITE);
mXAnimator.setDuration(1000);

// 通过线性改变mYMove值来实现垂直方向翻转无限循环
mYAnimator = ObjectAnimator.ofInt(this, "mYMove", getHeight() / 2, -getHeight() / 2);
mYAnimator.setRepeatCount(ValueAnimator.INFINITE);
mYAnimator.setRepeatMode(ValueAnimator.REVERSE);
mYAnimator.setDuration(10000);

// 水平垂直动画融合
mAnimatorSet = new AnimatorSet();
mAnimatorSet.playTogether(mXAnimator, mYAnimator);
// 必须加上线性均匀属性值变化方式,否则动画会有卡顿
mAnimatorSet.setInterpolator(new LinearInterpolator());

mAnimatorSet.start();

mIsAnimating = true;
}

private void stopAnimation() {
if (mXAnimator != null) {
mXAnimator.cancel();
mXAnimator = null;
}

mIsAnimating = false;
}
}

这边讲几点跟上面的单纯的波浪Demo的不同之处:

  1. 我们将invalidate()放到了私有变量mXMove和mYMove的set方法里面,这跟上面的加一个listener的效果是一样的。
  2. 因为垂直方向的动画范围是根据整个View的高度定的,而在第一次onAttachedToWindow()的时候,View还没有初始化好,获取不到高度,会为0,所以,我们将启动动画的工作放到了onDraw()中,同时制定了一个标识变量,防止反复启动动画。
  3. 设置BitmapShader的工作放到了setTextColor()方法里面,而不是构造函数里面。因为TextView初始化肯定会调用setTextColor()方法,更重要的是,在APP运行过程中,要支持动态更改TextView的字体颜色

其他

在源工程中,还有这么一段代码:

1
2
3
TitanicTextView tv = (TitanicTextView) findViewById(R.id.my_text_view);
tv.setTypeface(Typefaces.get(this, "Satisfy-Regular.ttf"));
new Titanic().start(tv);

这段代码是用来更改字体的,所以源工程的效果图那么好看。
具体就不展开了,找到好看的字体就可以去替换了。