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定义相关文章会减少,会在性能和播放相关方面转移,,欢迎点赞加关注。

相关推荐
huoyueyi7 分钟前
超详细Chatbot UI的配置及使用
前端·ui·chatgpt
Qlittleboy19 分钟前
vue的elementUI 给输入框绑定enter事件失效
前端·vue.js·elementui
Violet_Stray33 分钟前
用bootstrap搭建侧边栏
前端·bootstrap·html
软件聚导航34 分钟前
对uniApp 组件 picker-view 的二次封装,实现日期,时间、自定义数据滚动选择,容易扩展
前端·javascript·html
码农丁丁1 小时前
[前端]mac安装nvm(node.js)多版本管理
前端·macos·node.js·nvm
正小安1 小时前
Vite 系列课程|1课程道路,2什么是构建工具
前端·vite
阿髙2 小时前
ios的safari下载文件 文件名乱码
前端·axios·safari·下载
LaiJying2 小时前
图书馆管理系统(四)基于jquery、ajax--完结篇
前端·ajax·jquery
风清云淡_A2 小时前
【原生js案例】ajax的简易封装实现后端数据交互
前端·javascript
Jack_Kuo2 小时前
【docker】如何打包前端并运行
前端·docker·容器