自定义动态切换字符的TextView

本文介绍了一个自定义TextView的实现过程,包括动态放大显示效果及完整的动画效果,涉及到字符绘制、动画控制等方面。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

最近项目需要让搜索框的预选词自动切换,就像360助手、应用宝之类的搜索词会动态往上顶进行切换,然后去搜了一下开源项目,找到了一个效果挺炫的开源项目:HTextView。个人觉得比360之类的好看多了,当然了,在拿来用也要学习下别人是怎么实现的,最好的方法就是自己也实现一个,下面就以HTextView中的缩放效果进行实现。因为代码是以个人的习惯写的,和开源项目中有点出入,但整体实现思想是一样,更多动画效果可看开源项目。

先来看下要实现的最终效果:


下面开始来实现

一. 自定义TextView

要实现文字的动态效果,就要继承TextView并重写onDraw()方法,来自己绘制字符,绘制字符的方法为canvas.drawText():

/**
 * Draw the text, with origin at (x,y), using the specified paint.
 * The origin is interpreted based on the Align setting in the paint.
 *
 * @param text  The text to be drawn
 * @param start The index of the first character in text to draw
 * @param end   (end - 1) is the index of the last character in text to draw
 * @param x     The x-coordinate of the origin of the text being drawn
 * @param y     The y-coordinate of the baseline of the text being drawn
 * @param paint The paint used for the text (e.g. color, size, style)
 */
public void drawText(@NonNull String text, int start, int end, float x, float y, @NonNull Paint paint)
这些参数还是比较好理解的, text:要绘制的字符串; start:text绘制起始索引; end:text绘制终止索引; x:绘制的X坐标; y:文字的基线; paint:画笔。关于文字的一些属性如baseline,有兴趣自己查资料,这里不说明这些东西。

有了这个我们就可以绘制字符串了:

mText = getText();
// 绘制字符
canvas.drawText(String.valueOf(mText.charAt(i)), 0, 1, x, getBaseline(), mPaint);
// 绘制字符串
canvas.drawText(mText, 0, mText.length(), x, getBaseline(), mPaint);
后面的绘制操作其实都是通过这个方法来完成的,而动画效果通过设置画笔Paint来控制。

二. 动态放大显示
我们从基本的一点一点搭建,首先来实现一个从无到有逐渐放大的效果:


转化GIF图片显示的有点快- -,将就看个效果。

设置的动画效果是这样的:
1. 每个字符从0->1放大为TextSize大小;
2. 同样透明为0->255变换,和放大同步;
3. 每个字符动画时间为400MS;
4. 每个字符动画的启动延迟递增20MS,比如第2个字符为20MS,第4个字符为60MS;

在写自定义TextView前先来定义个动画接口:

public interface ITextAnimation {
    /**
     * Starts the animation.
     */
    void start();

    /**
     * Stops the animation.
     */
    void stop();

    /**
     * Indicates whether the animation is running.
     *
     * @return True if the animation is running, false otherwise.
     */
    boolean isRunning();
}
其实没什么特别的,我就直接把Animatable接口拷贝过来,这样看着规范点。

下面看自定义TextView实现代码:

public class TestTextView extends TextView implements ITextAnimation {

    private static final int MAX_TEXT_LENGTH = 100;
    // 每个字符动画时间
    private static final int CALC_TIME = 400;
    // 每个字符动画启动的间隔延迟时间,递增
    private static final int EACH_CHAR_DELAY = 20;

    // 是否为动画Text
    private boolean mIsAnimationText;
    private Paint mPaint;
    // 要绘制的字符串
    private CharSequence mText;
    // 字体大小
    private float mTextSize;
    // 字符个数
    private int mTextCount;
    // 动画
    private ValueAnimator mValueAnimator;
    // 动画时间
    private int mDuration;
    // 动画进度
    private int mProgress;
    // 字符的X坐标
    private float[] mCharOffset;


    public TestTextView(Context context) {
        this(context, null);
    }

    public TestTextView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public TestTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (!mIsAnimationText) {
            super.onDraw(canvas);
            return;
        }
        for (int i = 0; i < mTextCount; i++) {
            // 字符动画启动的时间
            int startTime = i * EACH_CHAR_DELAY;
            // 动画进行的进度百分比,0→1
            float percent = (mProgress - startTime) * 1.0f / CALC_TIME;
            if (percent > 1.0f) {
                percent = 1;
            } else if (percent < 0) {
                percent = 0;
            }
            // 透明度
            int alpha = (int) (255 * percent);
            // 大小
            float size = mTextSize * percent;
            mPaint.setAlpha(alpha);
            mPaint.setTextSize(size);
            // 绘制字符
            canvas.drawText(String.valueOf(mText.charAt(i)), 0, 1, mCharOffset[i], getBaseline(), mPaint);
        }
    }

    @Override
    public void setText(CharSequence text, BufferType type) {
        super.setText(text, type);
        mIsAnimationText = false;
        _initVariables();
    }

    /**
     * @param text
     */
    public void setAnimationText(CharSequence text) {
        setText(text);
        mIsAnimationText = true;
        _calcCharOffset();
        _initAnimator();
    }

    /**
     * 初始化变量
     */
    private void _initVariables() {
        if (mPaint == null) {
            mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        }
        mPaint.setColor(getCurrentTextColor());
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setTextSize(getTextSize());
        mText = getText();
        mTextSize = getTextSize();
        mTextCount = mText.length();
    }

    /**
     * 初始化动画
     */
    private void _initAnimator() {
        mDuration = CALC_TIME + (mTextCount - 1) * EACH_CHAR_DELAY;
        mValueAnimator = ValueAnimator.ofInt(0, mDuration);
        mValueAnimator.setDuration(mDuration);
        mValueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mProgress = (int) animation.getAnimatedValue();
                invalidate();
            }
        });
        // 直接启动动画
        start();
    }

    /**
     * 计算字符的X坐标
     */
    private void _calcCharOffset() {
        if (mCharOffset == null) {
            mCharOffset = new float[MAX_TEXT_LENGTH];
        }
        int offset = 0;
        mCharOffset[0] = 0;
        for (int i = 0; i < mTextCount - 1; i++) {
            offset += mPaint.measureText(String.valueOf(mText.charAt(i)));
            mCharOffset[i + 1] = offset;
        }
    }

    /***************************************************************************/

    @Override
    public void start() {
        if (isRunning()) {
            stop();
        }
        mValueAnimator.start();
    }

    @Override
    public void stop() {
        mValueAnimator.end();
    }

    @Override
    public boolean isRunning() {
        return mValueAnimator != null || mValueAnimator.isRunning();
    }
}
整个代码比较简单,我把每个要做的操作分成不同的方法调用,这里说几个注意的地方:
1. 覆写了setText(CharSequence text, BufferType type),TextView在设置显示字符串时都会调用到这个方法,在这里做了两个操作,设置为不执行动画绘制(mIsAnimationText = false),初始化变量_initVariables(),就是记录下文字信息;
2. 对外提供了setAnimationText(CharSequence text)方法,用来执行动画绘制,这里调用了setText(text)最终也会调用上面覆写的方法,设置执行动画绘制;
3. 调用mPaint.measureText(String.valueOf(mText.charAt(i)))计算字符的X坐标,注意循环中的mCharOffset[i + 1] = offset,这里进行+1处理是第n个字符的偏移值为前n-1个字符的宽度之和;
4. 动画执行时间mDuration为最后一个字符的结束动画时间,看公式应该能理解;
5. 在绘制的时候计算了每个字符的动画执行百分比,并设置对应的透明度和大小,然后一个一个绘制字符;

三. 整个动画效果

先来整理下整个动画要实现的效果:
1. 找出切换字符串的重复字符,对这些字符进行平移动画;
2. 将前一个字符串逐渐缩小消失,动画时间为(mDuration/2);
3. 对当前字符串进行放大操作;

其实这个实现主要就是对重复字符的处理,消失动画其实和前面的放大动画没本质差别,要做重复字符串处理首先当然要获取前后两个字符串和计算各自的字符偏移值:

private void _initVariables() {
    // 略......
    mOldText = mText;
    mText = getText();
    mOldTextSize = mTextSize;
    mTextSize = getTextSize();
    mOldTextCount = mTextCount;
    mTextCount = mText.length();

    mOldPaint.setColor(mPaint.getColor());
    mOldPaint.setTextSize(mOldTextSize);
    mPaint.setColor(getCurrentTextColor());
    mPaint.setTextSize(getTextSize());
}

private void _calcCharOffset() {
    // 略......
    // 计算当前字符X坐标
    int offset = 0;
    mCharOffset[0] = 0;
    for (int i = 0; i < mTextCount - 1; i++) {
        offset += mPaint.measureText(String.valueOf(mText.charAt(i)));
        mCharOffset[i + 1] = offset;
    }
    // 计算旧字符X坐标
    offset = 0;
    mOldCharOffset[0] = 0;
    for (int i = 0; i < mOldTextCount - 1; i++) {
        offset += mOldPaint.measureText(String.valueOf(mOldText.charAt(i)));
        mOldCharOffset[i + 1] = offset;
    }
    // 查找重复字符
    mRepeatCharList = RepeatCharHelper.findRepeatChar(mText, mOldText);
}
下面看重复字符串的处理操作,这里定义一个重复字符的参数实体:
public class RepeatChar {

    // 上一个索引
    private int oldIndex;
    // 当前索引
    private int curIndex;

    public RepeatChar(int oldIndex, int curIndex) {
        this.oldIndex = oldIndex;
        this.curIndex = curIndex;
    }

    public int getOldIndex() {
        return oldIndex;
    }

    public void setOldIndex(int oldIndex) {
        this.oldIndex = oldIndex;
    }

    public int getCurIndex() {
        return curIndex;
    }

    public void setCurIndex(int curIndex) {
        this.curIndex = curIndex;
    }
}
保存了旧索引和当前索引,下面是重复字符串处理操作:
public final class RepeatCharHelper {

    private RepeatCharHelper() {
        throw new RuntimeException("RepeatCharHelper cannot be initialized!");
    }

    /**
     * 查找重复的字符
     * @param curText 当前字符串
     * @param oldText 旧的字符串
     * @return
     */
    public static List<RepeatChar> findRepeatChar(CharSequence curText, CharSequence oldText) {
        List<RepeatChar> charList = new ArrayList<>();
        if (curText == null || oldText == null) {
            return charList;
        }
        // 用来保存已经重复的当前字符串索引
        Set<Integer> skip = new HashSet<>();
        for (int i = 0; i < oldText.length(); i++) {
            char c = oldText.charAt(i);
            for (int j = 0; j < curText.length(); j++) {
                if (!skip.contains(j) && c == curText.charAt(j)) {
                    skip.add(j);
                    charList.add(new RepeatChar(i, j));
                    break;
                }
            }
        }
        return charList;
    }

    /**
     * 判断旧字符是否需要移动
     * @param oldIndex  旧索引
     * @param charList  重复的字符列表
     * @return  当前字符索引
     */
    public static int needMove(int oldIndex, List<RepeatChar> charList) {
        for (RepeatChar repeatChar : charList) {
            if (repeatChar.getOldIndex() == oldIndex) {
                return repeatChar.getCurIndex();
            }
        }
        return -1;
    }

    /**
     * 判断当前字符是否要做变换处理
     * @param curIndex  字符索引
     * @param charList  重复的字符列表
     * @return
     */
    public static boolean isNoChange(int curIndex, List<RepeatChar> charList) {
        for (RepeatChar repeatChar : charList) {
            if (repeatChar.getCurIndex() == curIndex) {
                return true;
            }
        }
        return false;
    }
}
其实也没什么,就是遍历出重复的字符并保存旧索引和当前索引。

剩下要做的就是绘制操作了:

@Override
protected void onDraw(Canvas canvas) {
    if (!mIsAnimationText) {
        super.onDraw(canvas);
        return;
    }
    // 变换百分比
    float percent;
    // 2倍速百分比
    float twoPercent = 0;

    // 绘制之前字符串
    for (int i = 0; i < mOldTextCount; i++) {
        percent = mProgress * 1f / mDuration;
        if (twoPercent < 1f) {
            twoPercent = percent * 2f;
            twoPercent = twoPercent > 1.0f ? 1.0f : twoPercent;
        }
        // 判断是否为重复字符
        int curIndex = RepeatCharHelper.needMove(i, mRepeatCharList);
        if (curIndex != -1) {
            // 计算偏移值
            float offset = (mCharOffset[curIndex] - mOldCharOffset[i]) * percent;
            // 计算移动中的X坐标
            float moveX = mOldCharOffset[i] + offset;
            // 计算大小
            float size = mOldTextSize + (mTextSize - mOldTextSize) * percent;
            mOldPaint.setAlpha(255);
            mOldPaint.setTextSize(size);
            // 绘制字符
            canvas.drawText(String.valueOf(mOldText.charAt(i)), 0, 1, moveX, getBaseline(), mOldPaint);
        } else {
            // 在进度的50%完成操作
            // 透明度
            int alpha = (int) (255 * (1 - twoPercent));
            // 大小
            float size = mOldTextSize * (1 - twoPercent);
            mOldPaint.setAlpha(alpha);
            mOldPaint.setTextSize(size);
            // 绘制字符
            canvas.drawText(String.valueOf(mOldText.charAt(i)), 0, 1, mOldCharOffset[i], getBaseline(), mOldPaint);
        }
    }
    // 绘制当前字符串
    for (int i = 0; i < mTextCount; i++) {
        if (!RepeatCharHelper.isNoChange(i, mRepeatCharList)) {
            // 字符动画启动的时间
            int startTime = i * EACH_CHAR_DELAY;
            // 动画进行的进度百分比,0→1
            percent = (mProgress - startTime) * 1.0f / CALC_TIME;
            if (percent > 1.0f) {
                percent = 1;
            } else if (percent < 0) {
                percent = 0;
            }
            // 透明度
            int alpha = (int) (255 * percent);
            // 大小
            float size = mTextSize * percent;
            mPaint.setAlpha(alpha);
            mPaint.setTextSize(size);
            // 绘制字符
            canvas.drawText(String.valueOf(mText.charAt(i)), 0, 1, mCharOffset[i], getBaseline(), mPaint);
        }
    }
}
其实和之前相比就是多个重复字符操作,旧字符串消失操作没啥好讲的,重复字符要做的就是计算偏移坐标,相信看代码注释就清楚了。

整个自定义TextView就这些了,代码里并没有做太多的封装处理,有兴趣可以去看开源项目,里面还有很多更炫的动画效果。我这里只是简单实现一种动画效果,其它的其实就是替换不同的动画了,当然了,想做漂亮的动画往往是很费时的~

源码在这:https://github.com/Rukey7/AnimTextView

简介欢迎使用SuperTextView,这篇文档将会向你展示如何使用这个控件来提高你构建项目的效率。CoverSuperTextView继承自TextView,它能够大量的减少布局的复杂程度,并且使得一些常见的效果变得十分容易实现且高效。同时,它内置了动画驱动,你只需要合理编写Adjuster,然后startAnim()就可以看到预期的动画效果。它仅仅是一个控件,所以你可以不费吹灰之力的在你的项目中集成使用。特点你从此不必再为背景图编写和管理大量文件了。重新优化的状态图功能使得你能够精确的控制状态图的大小,以及在SuperTextView中的位置。支持设置圆角,并且能够精确的控制圆角位置。能够轻松的实现控件边框效果。支持文字描边,这使得空心文字效果成为了可能。内置动画驱动,你只需配合Adjuster合理的使用即可。Adjuster的出现,使得你对控件的绘制过程具有了掌控权,良好的设计使得它能够完美的实现绝大部分你脑海中的效果。使用指南支持的属性SuperTextView十分方便的支持在xml中直接设置属性,并且你能够立即看到效果。就像你平时使用TextView一样方便。<SuperTextView     android:layout_width="50dp"     android:layout_height="50dp"     //设置圆角。会同时作用于填充和边框(如果边框存在的话)。     //如果要设置为圆形,只需要把该值设置为宽或长的1/2即可。      app:corner="25dp"       //设置左上角圆角     app:left_top_corner="true"     //设置右上角圆角     app:right_top_corner="true"     //设置左下角圆角     app:left_bottom_corner="true"     //设置右下角圆角     app:right_bottom_corner="true"     //设置填充颜色     app:solid="@color/red"       //设置边框颜色     app:stroke_color="@color/black"       //设置边框的宽度。     app:stroke_width="2dp"      //放置一个drawable在背景层上。默认居中显示。     //并且默认大小为SuperTextView的一半。     app:state_drawable="@drawable/emoji"       //设置drawable的显示模式。可选值如下:     // left、top、right、bottom、center(默认值)、     //leftTop、rightTop、leftBottom、rightBottom、     //fill(充满整个SuperTextView,此时会使设置drawable的大小失效)     app:state_drawable_mode="center"      //设置drawable的height     app:state_drawable_height="30dp"     //设置drawable的width     app:state_drawable_width="30dp"     //设置drawble相对于基础位置左边的距离     app:state_drawable_padding_left="10dp"     //设置drawble相对于基础位置上边的距离     app:state_drawable_padding_top="10dp"     // boolean类型。是否显示drawable。     //如果你想要设置的drawable显示出来,必须设置为true。     //当不想让它显示时,再设置为false即可。     app:isShowState="true"      //是否开启文字描边功能。     //注意,启用这个模式之后通过setTextColor()设置的颜色将会被覆盖。     //你需要通过text_fill_color来设置文字的颜色。     app:text_stroke="true"      // 文字的描边颜色。默认为Color.BLACK。     app:text_stroke_color="@color/black"     // 文字描边的宽度。     app:text_stroke_width="1dp"     // 文
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值