Android 点阵体文字特效,祝福永远的女神

前言

就在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;
            }
        }
    }
}
相关推荐
工程师老罗3 小时前
如何在Android工程中配置NDK版本
android
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端