前言
就在3月7日-3月8日,女孩们和女人们迎来了自己的节日,有些人过了"慷慨的"半天假期,有些人还是一直忙碌,更有一些人都忘记甚至不知道有这个节日。工作的忙碌,成了所有人忙碌的生活,时间的流逝,让我们忘记了年轻时的自己。人生本就苦短,有几次能清风拂面,心旷神怡的日子,特别是对于大部分女孩、妻子、母亲而言,从人生的起点到终点,有干不完的家务,走不完的弯路,消除不了的工作壁垒.....,对于她们,关心是非常必要的。
这里,本篇做此图祝福她们。
字体原理
本篇主要利用"点阵体"实现文字特效,我们先来了解下什么是"点阵体"呢?在字体发展的历史上,鉴于一些显示器本身的展示清晰度并不高,一些情况下,文字的展示使用类似LED那种展示,LED的话,只要确定LED单元在二维数组的点位索引,通过一组LED同时发光,就能展示出特定的文字。
其实,早期的缺陷也是非常明显,如果要换一个显示器,那么意味着由需要重新排列,此外,最大的缺陷是不能缩放,颜色控制起来也比较复杂。
后来,随着显示技术的发展,诞生了使用矢量字体来处理这种问题的方法。
关于矢量字体
矢量字体有以下特性
- 可测量: 大小可以测量
- 可以缩放: 大小可以调整
- 矢量性质: 压扁、缩放不会失真
- 颜色变化: 可以实现颜色
当然,当前的字体更加发达,使用了大量的贝塞尔曲线来进行绘制,因为其不仅仅便于描述路径,而且还能设计出笔锋,可想而知,如果没有贝塞尔曲线,字体的可能会非常昂贵。
其实,在本篇之前,我们利用实现过自定义矢量字体,文章可参考《Android 自定义液晶体数字》,在这篇文章中,我们虽然没有使用到贝塞尔曲线,但总体上来说,本篇实现的字体也是矢量字体。
以上是矢量字体,那点阵体现状如何呢?
关于点阵字体
实际上,目前使用点阵体的很多都是LED霓虹灯之类的,用途其实已经远远被矢量字体超越了,甚至一些情况下,点阵体也是通过矢量字体生成的,通过这种方式,就可以规避点阵体的无法缩放、压扁等问题。
在Android中,Paint都自动加载了矢量字体,我们之前有一篇文章《Android 实现LED展示效果》中,专门使用了这种方式来生成点阵体文字。
目前,点阵体还有一个用途就是文字识别了,很多文字识别软件依靠点阵计算出相似度,匹配出相应的文字。
本篇原理
本篇,我们实际上也是通过矢量字体来生成点阵,当然,本篇我们会使用一种更加巧妙的方式,在点阵范围内实现文字、表情符😊、圆圈等绘制。 主要分为以下步骤
- 测量文字区域,用于确定展示位置
- Path提取,提取文字的绘制Path
- 碰撞检测,计算出重合点,确定粒子绘制位置索引。
- 绘制图案
这里,我们有遇到了碰撞检测,实际上,利用图片分片也是可以的,但是这里用碰撞检测的原因是可以在更小范围内计算出重合点。我们之前的文章中,介绍过碰撞检测,通过《Android Region碰撞检测问题优化》我们知道,Region的碰撞可以被优化,不需要借助算法就能实现碰撞检测。
实现
初始化Paint
接下来我们实现本篇的内容,首先,Paint初始化是必要的常规步骤,这里要注意一个问题,如果使用了BlendMode,可能绘制不了文字。
java
private void initPaint() {
//否则提供给外部纹理绘制
mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mPaint.setAntiAlias(false);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setTextSize(dp2px(150));
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
//PaintCompat.setBlendMode(mPaint, BlendModeCompat.LIGHTEN);
}
初始化关键变量
下面初始化变量,用来记录重合点位、碰撞区域检测,其中,我们本篇会用到Region,Region的用法可以参考我之前的文章。
java
//记录点位,保存重合点位
List<Particle> points = new ArrayList<>();
Path textPath = new Path(); // 文本路径
//文本区域,这里是指文字路径区域,并不是文字大小区域
Region textPathRegion = new Region();
//这里才是文本区域
Region mainRegion = new Region();
//文本区域边界
RectF textRect = new RectF();
//分片区域边界
Rect measureRect = new Rect();
当然,我们这里也要将点位作为粒子存储,下面是粒子的描述对象,另外,radius不能是小于0的,否则会出现文本测量异常。
java
static class Particle extends PointF {
float t; //矢量性质,用来调整radius的伸缩值
int color; // 颜色
float radius; //当前半径
float range; //半径最大区域
public void update() {
radius = radius + t;
if (radius >= range || radius <= 0) {
t = -t;
}
if (radius < 0) {
radius = 0; //防止小于0时,文字测量异常
}
if(radius > range){
radius = range;
}
}
}
测量和提取Path
为什么要提取Path呢,提取Path是为了Region服务,这样可以计算出文字染色的区域。
这里首先要记住,提取的Path前最好计算出文本的展示位置,否则,绘制的时候需要做一些平移。另外一点是,最好在提取Path之前设置字体,也是方便区域测量,减少后期缩放操作。
java
float measureTextWidth = mPaint.measureText(text, 0, text.length); //测量文本宽度
float baseline = getTextPaintBaseline(mPaint); //获取基线
mPaint.getTextPath(text, 0, text.length, -(measureTextWidth / 2f), baseline, textPath);
下面,将Path设置到Region中,通过下面的操作,Region只包含文字染色的区域,其他区域都是外部区域,
java
textPath.computeBounds(textRect, true);
mainRegion.set((int) textRect.left, (int) textRect.top, (int) textRect.right, (int) textRect.bottom);
textPathRegion.setPath(textPath, mainRegion);
碰撞检测
这里,我们使用文本区域,而不是Canvas 区域,可以有效减少计算量,而step是分片(矩形)单元的大小,我们通过measureRect计算出中心点,然后利用上面的Region的contains方法去检测点位,而我们知道,contains是精确测量,因此,也要避免性能问题。
java
float textRectWidth = textRect.width(); //文本区域
float textRectHeight = textRect.height(); //文本区域
int step =3;
int index = 0; //容器索引
for (int i = 0; i < textRectHeight; i += step) {
for (int j = 0; j < textRectWidth; j += step) {
int row = (int) (-textRectHeight / 2 + i * step);
int col = (int) (-textRectWidth / 2 + j * step);
measureRect.set(col, row, col + step, row + step);
//检测下面点位是不是和文本的染色区域重合
if (!textPathRegion.contains(measureRect.centerX(), measureRect.centerY())) {
continue; //如果不重合,这个位置不能绘制
}
//从容器中取出粒子
Particle p = points.size() > index ? points.get(index) : null;
index++;
if (p == null) {
p = new Particle();
points.add(p);
}
//保存点位
p.x = measureRect.centerX();
p.y = measureRect.centerY();
int randomInt = 1 + (int) (Math.random() * 100);
float t = (float) ((randomInt % 2 == 0 ? -1f : 1f) * Math.random());
p.color = Color.BLACK;
p.radius = (float) (step * random);
p.range = step * 5;
p.t = t;
}
}
while (points.size() > index + 1) { //删除脏数据
points.remove(points.size() - 1);
}
绘制
下面是绘制代码
java
for (int i = 0; i < points.size(); i += 2) {
Particle point = points.get(i);
mPaint.setColor(point.color);
canvas.drawCircle(point.x,point.y,point.radius,mPaint);
point.update();
}
invalidate();
点位着色
我们先绘制一下点位
java
canvas.drawCircle(point.x,point.y,point.radius,mPaint);
效果还不错
继续上色
java
private float floatRandom(){
return (float)Math.random();
}
p.color = argb(floatRandom(),floatRandom(),floatRandom(),floatRandom());
有些凌乱,稍微挑战下背景和点位密度
还是不够亮,不够亮的问题怎么解决,当然是使用HLS或者HSV的颜色空间了,下面我们使用HLS来优化亮度
java
hsl[0] = (float) (random * 360);
hsl[1] = 0.5f;
hsl[2] = 0.5f; //最亮
p.color = HSLToColor(hsl);
好看多了
文字着色
文字说色是使用文字去这些点位绘制
java
canvas.drawText(....)
普通文字说色
这里我们用字幕C去绘制,感觉还好。
表情符着色
下面,我们用😊表情符着色看下效果,还是不错的
动图实现
动图实际上,我们要调整radius或者textSize实现
文本动起来
主要功能已经实现了,下面,我们,我们实现一些动画效果,首先是表情符动起来 当然,这里要让表情符动起来,需要注意基线要根据TextSize重新计算,我们可以把radius设置到Paint.setTextSize中
java
text = "🤭".toCharArray();
当然,鲜花也可以动起来
是不是很简单呢,实际上,核心功能已经实现了,我们实现开头的效果看一下,下面改成画圆。
祝福文字
我们将绘制的文本改成"永远的女神",效果如下
总结
本篇到这里就结束了,本篇的核心内容就是利用点阵字体的思想,实现文字描绘特效,在本篇,我们还回忆了以前几篇文章,其中最重要的还是"碰撞检测"相关,另外我们也可以了解到字体设计的核心思想,以及如何让颜色变的更亮。
好了,本篇就到这里,下面我们附上本篇的源码。
java
public class WordParticleView extends View {
private char[] text = "永远的女神".toCharArray();
private final DisplayMetrics mDM;
Paint.FontMetrics fm = new Paint.FontMetrics();
boolean shouldUpdateTextPath = true;
private TextPaint mPaint;
{
mDM = getResources().getDisplayMetrics();
initPaint();
}
private void initPaint() {
//否则提供给外部纹理绘制
mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mPaint.setAntiAlias(false);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setTextSize(dp2px(150));
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
PaintCompat.setBlendMode(mPaint, BlendModeCompat.LIGHTEN);
}
public WordParticleView(Context context) {
this(context, null);
}
public WordParticleView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WordParticleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize;
}
setMeasuredDimension(widthSize, heightSize);
}
public void setText(String text) {
if (text == null) return;
shouldUpdateTextPath = true;
this.text = text.toCharArray();
}
//记录点位,保存重合点位
List<Particle> points = new ArrayList<>();
Path textPath = new Path(); // 文本路径
//文本区域,这里是指文字路径区域,并不是文字大小区域
Region textPathRegion = new Region();
//这里才是文本区域
Region mainRegion = new Region();
//文本区域边界
RectF textRect = new RectF();
//分片区域边界
Rect measureRect = new Rect();
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
shouldUpdateTextPath = true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (text == null) return;
int saveRecord = canvas.save();
canvas.translate(getWidth() / 2f, getHeight() / 2f);
float measureTextWidth = mPaint.measureText(text, 0, text.length);
float baseline = getTextPaintBaseline(mPaint);
final int step = 3; //步长
if (shouldUpdateTextPath) {
textPath.reset();
mPaint.getTextPath(text, 0, text.length, -(measureTextWidth / 2f), baseline, textPath);
shouldUpdateTextPath = false;
//染色区域设置
textPath.computeBounds(textRect, true);
mainRegion.set((int) textRect.left, (int) textRect.top, (int) textRect.right, (int) textRect.bottom);
textPathRegion.setPath(textPath, mainRegion);
float textRectWidth = textRect.width();
float textRectHeight = textRect.height();
int index = 0;
for (int i = 0; i < textRectHeight; i += step) {
for (int j = 0; j < textRectWidth; j += step) {
int row = (int) (-textRectHeight / 2 + i * step);
int col = (int) (-textRectWidth / 2 + j * step);
measureRect.set(col, row, col + step, row + step);
if (!textPathRegion.contains(measureRect.centerX(), measureRect.centerY())) {
continue;
}
Particle p = points.size() > index ? points.get(index) : null;
index++;
if (p == null) {
p = new Particle();
points.add(p);
}
p.x = measureRect.centerX();
p.y = measureRect.centerY();
double random = Math.random();
hsl[0] = (float) (random * 360);
hsl[1] = 0.5f;
hsl[2] = 0.5f; //最亮
p.color = HSLToColor(hsl);
int randomInt = 1 + (int) (Math.random() * 100);
float t = (float) ((randomInt % 2 == 0 ? -1f : 1f) * Math.random());
p.radius = (float) (step * random);
p.range = step * 5;
p.t = t;
}
}
while (points.size() > index + 1) {
points.remove(points.size() - 1);
}
}
float textSize = mPaint.getTextSize();
for (int i = 0; i < points.size(); i += 2) {
Particle point = points.get(i);
mPaint.setColor(point.color);
canvas.drawCircle(point.x,point.y,point.radius,mPaint);
point.update();
}
mPaint.setTextSize(textSize);
canvas.restoreToCount(saveRecord);
postInvalidateDelayed(0);
}
public float getTextPaintBaseline(Paint p) {
p.getFontMetrics(fm);
Paint.FontMetrics fontMetrics = fm;
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}
public static int argb(float alpha, float red, float green, float blue) {
return ((int) (alpha * 255.0f + 0.5f) << 24) |
((int) (red * 255.0f + 0.5f) << 16) |
((int) (green * 255.0f + 0.5f) << 8) |
(int) (blue * 255.0f + 0.5f);
}
float[] hsl = new float[3];
@ColorInt
public static int HSLToColor(@NonNull float[] hsl) {
final float h = hsl[0];
final float s = hsl[1];
final float l = hsl[2];
final float c = (1f - Math.abs(2 * l - 1f)) * s;
final float m = l - 0.5f * c;
final float x = c * (1f - Math.abs((h / 60f % 2f) - 1f));
final int hueSegment = (int) h / 60;
int r = 0, g = 0, b = 0;
switch (hueSegment) {
case 0:
r = Math.round(255 * (c + m));
g = Math.round(255 * (x + m));
b = Math.round(255 * m);
break;
case 1:
r = Math.round(255 * (x + m));
g = Math.round(255 * (c + m));
b = Math.round(255 * m);
break;
case 2:
r = Math.round(255 * m);
g = Math.round(255 * (c + m));
b = Math.round(255 * (x + m));
break;
case 3:
r = Math.round(255 * m);
g = Math.round(255 * (x + m));
b = Math.round(255 * (c + m));
break;
case 4:
r = Math.round(255 * (x + m));
g = Math.round(255 * m);
b = Math.round(255 * (c + m));
break;
case 5:
case 6:
r = Math.round(255 * (c + m));
g = Math.round(255 * m);
b = Math.round(255 * (x + m));
break;
}
r = constrain(r, 0, 255);
g = constrain(g, 0, 255);
b = constrain(b, 0, 255);
return Color.rgb(r, g, b);
}
private static int constrain(int amount, int low, int high) {
return amount < low ? low : Math.min(amount, high);
}
static class Particle extends PointF {
float t;
int color;
float radius;
float range;
public void update() {
radius = radius + t;
if (radius >= range || radius <= 0) {
t = -t;
}
if (radius < 0) {
radius = 0;
}
if(radius > range){
radius = range;
}
}
}
}