查看原文
其他

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

时光少年 鸿洋
2024-08-24

本文作者


作者:时光少年

链接:

https://juejin.cn/post/7343915907938451483

本文由作者授权发布。


前言

就在3月7日-3月8日,女神们迎来了自己的节日,有些人过了“慷慨的”半天假期,有些人还是一直忙碌,更有一些人都忘记甚至不知道有这个节日。工作的忙碌,成了所有人忙碌的生活,时间的流逝,让我们忘记了年轻时的自己。人生本就苦短,有几次能清风拂面,心旷神怡的日子,特别是对于大部分女孩、妻子、母亲而言,从人生的起点到终点,有干不完的家务,走不完的曲折之路,消除不了的工作壁垒.....,对于她们,关心是非常必要的。
这里,本篇做此图祝福她们。


1字体原理


本篇主要利用“点阵体”实现文字特效,我们先来了解下什么是“点阵体”呢?在字体发展的历史上,鉴于一些显示器本身的展示清晰度并不高,一些情况下,文字的展示使用类似LED那种展示,LED的话,只要确定LED单元在二维数组的点位索引,通过一组LED同时发光,就能展示出特定的文字。
其实,早期的点阵体文字缺陷也是非常明显,如果要换一个显示器,那么意味着由需要重新排列,此外,最大的缺陷是不能缩放、拉伸,颜色控制起来也比较复杂。
后来,随着显示技术的发展,诞生了使用矢量字体来处理这种问题的方法。

关于矢量字体

矢量字体有以下特性:
  • 可测量:大小可以测量。
  • 可以缩放:大小可以调整。
  • 矢量性质:拉伸、压扁、缩放不会失真。
  • 颜色变化:可以实现颜色变化。

当然,当前的字体更加发达,使用了大量的贝塞尔曲线来进行绘制,因为其不仅仅便于描述路径,而且还能设计出笔锋,可想而知,如果没有贝塞尔曲线,字体的可能会非常昂贵。


其实,在本篇之前,我们利用实现过自定义矢量字体,文章可参考《Android 自定义液晶体数字》,在这篇文章中,我们虽然没有使用到贝塞尔曲线,但总体上来说,本篇实现的字体也是矢量字体。


以上是矢量字体,那点阵体现状如何呢?

关于点阵字体

实际上,目前使用点阵体的很多都是LED霓虹灯之类的,用途其实已经远远被矢量字体超越了,甚至一些情况下,点阵体也是通过矢量字体生成的,通过这种方式,就可以规避点阵体的无法缩放、压扁等问题。

在Android中,Paint都自动加载了矢量字体,我们之前有一篇文章《Android 实现LED展示效果》中,专门使用了这种方式来生成点阵体文字。

https://juejin.cn/post/7304973928039153705



目前,点阵体还有一个用途就是文字识别了,很多文字识别软件依靠点阵计算出相似度,匹配出相应的文字。

通过上面的对比我们可以知道,矢量字体可以实现点阵字体,但反过来是不行的。

2本篇原理


本篇,我们实际上也是通过矢量字体来生成点阵,当然,本篇我们会使用一种更加巧妙的方式,在点阵范围内实现文字、表情符、圆圈等绘制。主要分为以下步骤:
  • 测量文字区域,用于确定展示位置。
  • Path提取,提取文字的绘制Path。
  • 碰撞检测,计算出重合点,确定粒子绘制位置索引。
  • 绘制图案。

这里,我们有遇到了碰撞检测,实际上,利用图片分片也是可以的,但是这里用碰撞检测的原因是可以在更小范围内计算出重合点。我们之前的文章中,介绍过碰撞检测,通过《Android Region碰撞检测问题优化》我们知道,Region的碰撞可以被优化,不需要借助算法就能实现碰撞检测。

https://juejin.cn/post/7310412252552085513


3实现

初始化Paint

接下来我们实现本篇的内容,首先,Paint初始化是必要的常规步骤,这里要注意一个问题,如果使用了BlendMode,可能绘制不了文字。
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的用法可以参考我之前的文章。
//记录点位,保存重合点位
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的,否则会出现文本测量异常。
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之前设置字体,也是方便区域测量,减少后期缩放操作。
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只包含文字染色的区域,其他区域都是外部区域,
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是精确测量,因此,也要避免性能问题。
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);
}


绘制

下面是绘制代码:
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();


点位着色

我们先绘制一下点位:
canvas.drawCircle(point.x,point.y,point.radius,mPaint);


效果还不错。
继续上色,这里我没给个随机颜色。
private float floatRandom(){
  return (float)Math.random();
}
....
//设置颜色
 p.color = argb(floatRandom(),floatRandom(),floatRandom(),floatRandom());



有些凌乱,稍微挑战下背景和点位密度,将View背景设置为黑色,然后step设置为5。
还是不够亮,不够亮的问题怎么解决,当然是使用HLS或者HSV的颜色空间了,下面我们使用HSL来优化亮度,hSL的第三个参数为两度,为0.5时最亮,过界或者小于这个值亮度会递减。第二个值为色彩饱和度,第一个值为色相。
  • 色相(H) 是色彩的基本属性,就是平常所说的颜色名称,如红色、黄色等。
  • 饱和度(S) 是指色彩的纯度,越高色彩越纯,低则逐渐变灰,取 0-1f 的数值。
  • 亮度(L) ,取 0-1f,增加亮度,颜色会向白色变化;减少亮度,颜色会向黑色变化。


上面是HSL模型,具体细节的话如下:
色相变化范围:


亮度变化范围:


调整代码逻辑:
hsl[0] = (float) (random * 360);  //色相
hsl[1] = 0.5f// 饱和度 - 颜色的浓度
hsl[2] = 0.5f;  //最亮 - 颜色的两度
p.color = HSLToColor(hsl);



好看多了。


文字着色

文字说色是使用文字去这些点位绘制。
canvas.drawText(....)


普通文字说色

这里我们用字幕C去绘制,感觉还好,就是亮度变差了,可能笔划不够粗,后续优化的可以取一些亮色的颜色。


表情符着色

下面,我们用😊表情符着色看下效果,还是不错的。


动图实现

动图实际上,我们要调整radius或者textSize实现

文本动起来

主要功能已经实现了,下面,我们,我们实现一些动画效果,首先是表情符动起来 当然,这里要让表情符动起来,需要注意基线要根据TextSize重新计算,我们可以把radius设置到Paint.setTextSize中。
 text = "🤭".toCharArray();


由于表情符属于文字,因此需要调整 textSize。


当然,鲜花也可以动起来:


是不是很简单呢,实际上,核心功能已经实现了,我们实现开头的效果看一下,下面改成画圆。
当然绘制圆是不需要设置textSize的,调整radius即可。


祝福文字

我们将绘制的文本改成“永远的女神”,效果如下:


4总结


本篇到这里就结束了,本篇的核心内容就是利用点阵字体的思想,实现文字描绘特效,在本篇,我们还回忆了以前几篇文章,其中最重要的还是“碰撞检测”相关,另外我们也可以了解到字体设计的核心思想,以及如何让颜色变的更亮。
好了,本篇就到这里,下面我们附上本篇的源码。
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 == nullreturn;
        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 == nullreturn;
        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, 0255);
        g = constrain(g, 0255);
        b = constrain(b, 0255);

        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;
            }
        }
    }
}


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

如何科学的进行Android包体积优化
Android项目开发模板推荐
如何实现JsBridge?怎么实现回调?


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存