Android 描边动画实现母亲节祝福效果

前言

话说我们之前的一些文章中"玩"过一些"文字游戏",哈哈,正经的说,应该被称为:文本处理。

主要有三篇

  • 包括Html自定义引擎
  • 文本展示优化
  • 女神节点阵体特效

有兴趣的同学可以看看我之前的文章。

今天是母亲节,其实这个日子很容易被忽略,同样父亲节也是,我们常常说要孝敬父母,然而,这两个假期连调休都没有。

之前,写点阵体特效特效一文恰逢「3·7女生节」和「3·8妇女节」,因此祝福了一下各位女生。当今的女性同胞们也非常优秀,也很有主见。但是在大环境比较差的时候,女性同胞受到的歧视风险和失业风险比以前更多。正如之前的一篇文章中说过,广大妇女们有干不完的家务和走不完的曲折之路,当然,不可否认有的女性地位很高,然而,我们也要尊重事实。

扯的多了,咱来了解下技术。

Android布局和绘制是一项非常重要的技能,无论Compose还是传统的View,都需要进行测量、布局和绘制。

但是,一些技能的掌握往往需要长时间的沉淀,当然,也要对现象进行总结。

有的时候,我们可能目标是做其他事,但是往往实现了意想不到的事。

正如这句话,我本来打算将字体录入GestureLibrary中,进行文字识别的,然而,在实现过程中发现,描边路径会增加字体的笔画,显然不适合录入手势库。然后我尝试将当个文字绘制到Bitmap,扩大采样步长,但无法确定起始点,因此也无法录入手势库。

当然,我们知道软键盘可以通过手写输入,理论上利用点阵体(HZK16字体文件)+ 汉明距离实现相似计算也可以实现,但是这种方式性能应该不高,后续我们研究下其他方案。

本篇效果

如下,就是本篇的实现效果

在这里,我们可以一个字一个字的绘制,当然,一个字一个字的绘制太慢了,作为开发者,咱最好不要浪费用户的时间。

尤其是某些地图类和打车类,动不动弄个开屏广告(带视频的),特别是用户设备电量不足时还弹出来,弄不好没电关机了,车到了都却联系不上司机。

上面的效果,步长太长,因此,点无法成线,下面将PathMeasure变短之后,效果就是连线了。

以上就是本篇的效果,其实本篇我们使用PathMeasure + Text Path 来实现,我们还可以用来实现3D文字或者描边特效。

原理

PathMeasure采样

在之前文章中,我们实现过《链接:过山车效果》,在这篇文章中我们了解了PathMeasure的基本用法。

和以往不同的是,之前的文章中,我们之前是主动构建各种图形,本篇我们通过Paint获取字体的Path,提供给PathMeasure采样。

注意点

Path长度问题

这里要注意的是,如果采样的是多个文字的Path,那么PathMeasure#getLength值默认是第一个文字的Path,而不是所有文字的总长。

Path 切换

我们拿到的Path是第一个文字的长度,那么如何切到下一个,这里就得使用pathMeasure#nextContour方法

采样

采样时需要注意的是,我们要将所有点拿到,之前博客通过PathMeasure#getSegment对片段采样,但不知道为什么有这种方式很多BUG,会出现不稳定的效果。

下面方式我们本篇不适用,会出现过程不稳定、不连续的问题

java 复制代码
mPathMeasure.getSegment(0, mPathMeasure.getLength() * value, mAnimPath, true);

本篇我们使用的是提前采样,将所有的点保存下来,这样我们自己来控制点的展示,从而避免了不稳定不连续的问题。

java 复制代码
public static List<PointF> textPathToPoints(String text, TextPaint paint) {
    Path fontPath = new Path();
    paint.getTextPath(text, 0, text.length(), 0f, paint.getFontSpacing(), fontPath);
    fontPath.close();
    PathMeasure pathMeasure = new PathMeasure(fontPath, false);
    List<PointF> points = new ArrayList<>();
    float[] pos = new float[2];
    do {
        float distance = 0f;
        while (distance < pathMeasure.getLength()) {
            distance += 5f;
            pathMeasure.getPosTan(distance, pos, null);
            points.add(new PointF(pos[0], pos[1]));
        }
    } while (pathMeasure.nextContour());
    return points;
}

好了,这是本篇的原理,下面来看看实现部分

本篇实现

下面是本篇的关键代码

绘制信息

我们定义一个类来保存和控制单个文字的动画状态和数据。

arduino 复制代码
static class FontText{
    int index; //当前文字在Text中的索引,用来实现文字单个文字偏移计算
    int currentSize; //用户控制绘制到什么位置
    int color; // 颜色
    List<PointF> pointFS; // 点位
    public FontText(int index,List<PointF> textPathToPoints,int color) {
        this.pointFS = textPathToPoints;
        this.index = index;
        this.color = color;
    }
}

计算和保存点位

这里,我们接着将信息保存到textPoints中,textPoints是一个Map对象

java 复制代码
if (textPoints.isEmpty()) {
    measureTextWidth = mTextPaint.measureText(text); //总长度
    for (int i = 0; i < text.length(); i++) {
        String word = text.substring(i, i + 1);
        textPoints.put(word,new FontText(i,textPathToPoints(word, mTextPaint),randomColor()));  //单个文字的点位
    }
}

绘制

下面是绘制部分,加了很多注释,主要做了如下几件事

  • 将文字水平居中绘制
  • 统计绘制完整的文字
  • 所有文字一起测量
  • 计算单个文字的X轴偏移
  • 计算单个文字的Points
  • 绘制文字
java 复制代码
int height = getHeight();
int width = getWidth();

float halfOfTextWidth = measureTextWidth / 2f; //计算中心点一半的长度

float baseline = getTextPaintBaseline(mTextPaint); //计算BaseLine
int count = canvas.save();
canvas.translate(width / 2f, height / 2f);  //平移到View中心点


float spanSize = measureTextWidth / textPoints.size();
int finishCount = 0;  //统计完成绘制的文字总数

for (Map.Entry<String,FontText> entry : textPoints.entrySet()){
   
    FontText textPoint = entry.getValue();
    int size = textPoint.currentSize;
    int pointSize = textPoint.pointFS.size();
    float offset = textPoint.index * spanSize; //文字X轴方向的偏移

    mPaint.setColor(textPoint.color);
    for (int i = 0; i < size; i++) {
        PointF pointF = textPoint.pointFS.get(i);
        //绘制点
        canvas.drawPoint(pointF.x - halfOfTextWidth + offset, pointF.y + baseline, mPaint);
    }
    textPoint.currentSize = Math.min(++size,pointSize);
    if(textPoint.currentSize == pointSize){
        finishCount++; // 当前绘制到的位置和pointSize
    }
}
canvas.restoreToCount(count);

if(finishCount == textPoints.size()){
    //所有的文字都完成绘制的,过1s之后重新绘制
    for (Map.Entry<String,FontText> entry : textPoints.entrySet()){
        entry.getValue().currentSize = 0;
    }
    postInvalidateDelayed(1000);
}else {
    postInvalidateDelayed(16);
}

好了,到这里本篇就结束了,代码量不大,很容易阅读,有问题咱评论区见。

总结

本篇实际上也是点阵体,比较适合做一些文字特效,相比而言,要比之前的一篇文章要简单的一些,基本上就是四部

  • 获取Path
  • Path转Point集合
  • Point绘制

下面我们贴出源码,供大家参考

源码

如下是本篇源码,没有特别的封装,使用的时候可能还需要你自行改造。

java 复制代码
public class FontPathToPointsView extends View {

    private TextPaint mTextPaint;
    private DisplayMetrics mDM;

    private String text = "母亲节快乐";
    private float measureTextWidth;
    float[] hslColor = new float[3];

    private final Map<String,FontText> textPoints = new ArrayMap<String,FontText>();

    private Paint.FontMetrics fm = new Paint.FontMetrics();
    private TextPaint mPaint;

    private void initPaint() {
        mDM = getResources().getDisplayMetrics();
        //否则提供给外部纹理绘制
        mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setAntiAlias(true);
        mTextPaint.setDither(true);
        mTextPaint.setStrokeCap(Paint.Cap.ROUND);
        mTextPaint.setStyle(Paint.Style.FILL);
        mTextPaint.setTextSize(sp2px(50));

        mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mPaint.setStrokeWidth(5f);
    }

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

    public FontPathToPointsView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public FontPathToPointsView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    {
        initPaint();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        textPoints.clear();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (text == null) {
            return;
        }

        if (textPoints.isEmpty()) {
            measureTextWidth = mTextPaint.measureText(text);
            for (int i = 0; i < text.length(); i++) {
                String word = text.substring(i, i + 1);
                textPoints.put(word,new FontText(i,textPathToPoints(word, mTextPaint),randomColor()));
            }
        }


        if (textPoints.isEmpty()) {
            return;
        }
        int height = getHeight();
        int width = getWidth();

        float halfOfTextWidth = measureTextWidth / 2f; //计算中心点一半的长度

        float baseline = getTextPaintBaseline(mTextPaint); //计算BaseLine
        int count = canvas.save();
        canvas.translate(width / 2f, height / 2f);  //平移到View中心点


        float spanSize = measureTextWidth / textPoints.size();
        int finishCount = 0;  //统计完成绘制的文字总数

        for (Map.Entry<String,FontText> entry : textPoints.entrySet()){

            FontText textPoint = entry.getValue();
            int size = textPoint.currentSize;
            int pointSize = textPoint.pointFS.size();
            float offset = textPoint.index * spanSize; //文字X轴方向的偏移

            mPaint.setColor(textPoint.color);
            for (int i = 0; i < size; i++) {
                PointF pointF = textPoint.pointFS.get(i);
                //绘制点
                canvas.drawPoint(pointF.x - halfOfTextWidth + offset, pointF.y + baseline, mPaint);
            }
            textPoint.currentSize = Math.min(++size,pointSize);
            if(textPoint.currentSize == pointSize){
                finishCount++; // 当前绘制到的位置和pointSize
            }
        }
        canvas.restoreToCount(count);

        if(finishCount == textPoints.size()){
            //所有的文字都完成绘制的,过1s之后重新绘制
            for (Map.Entry<String,FontText> entry : textPoints.entrySet()){
                entry.getValue().currentSize = 0;
            }
            postInvalidateDelayed(1000);
        }else {
            postInvalidateDelayed(16);
        }

    }

    public static List<PointF> textPathToPoints(String text, TextPaint paint) {
        Path fontPath = new Path();
        paint.getTextPath(text, 0, text.length(), 0f, paint.getFontSpacing(), fontPath);
        fontPath.close();
        PathMeasure pathMeasure = new PathMeasure(fontPath, false);
        List<PointF> points = new ArrayList<>();
        float[] pos = new float[2];
        do {
            float distance = 0f;
            while (distance < pathMeasure.getLength()) {
                distance += 5f;
                pathMeasure.getPosTan(distance, pos, null);
                points.add(new PointF(pos[0], pos[1]));
            }
        } while (pathMeasure.nextContour());
        return points;
    }

    public float dp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
    }

    public float sp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
    }

    public float getTextPaintBaseline(Paint p) {
        p.getFontMetrics(fm);
        Paint.FontMetrics fontMetrics = fm;
        return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
    }

    static class FontText{
        int index; //当前文字在Text中的索引
        int currentSize; //用户控制绘制到什么位置
        int color; // 颜色
        List<PointF> pointFS; // 点位
        public FontText(int index,List<PointF> textPathToPoints,int color) {
            this.pointFS = textPathToPoints;
            this.index = index;
            this.color = color;
        }
    }

    private int randomColor() {
        hslColor[0] = (float) (Math.random() * 360);
        hslColor[1] = 0.5f;
        hslColor[2] = 0.5f;
        return HSLToColor(hslColor);
    }

    @ColorInt
     static int HSLToColor(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);
    }
}

本篇到这里结束了,我们后续的View、Compose定义相关文章会减少,会在性能和播放相关方面转移,,欢迎点赞加关注。

相关推荐
腾讯TNTWeb前端团队3 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰7 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪7 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪7 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy8 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom8 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom8 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom8 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom9 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom9 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试