扩展Span支持绘制View,并转发手势,扩展文本动画

2,127 阅读13分钟

Span简单使用

在Android中,Span用来定义文本的样式。通过Span可以改变几个文字的颜色,让它们可点击,缩放文字大小甚至绘制自定义的项目符号点。

Span的价值是,可以将这些样式作用在字符级别或者段落级别。

Span是专门用来增强TextView样式的,Span通过改变TextPaint属性,在Canvas上绘制,甚至是改变文本的布局和影响像行高这样的元素,来改变文本样式。它可以被应用到部分或整段的文本中。

通常使用的套路是样式属性和Span组合使用,可以考虑将设置给TextView的样式属性作为一种“基本”样式,而 Span样式是应用在基本样式“之上”并且会覆盖基本样式的样式。例如,当给一个 TextView 设置了 textColor=”@color.blue” 属性且设置开头4个字符应用了 ForegroundColorSpan(Color.PINK),则开头4个字符会使用 span 设置的粉色,而其他文本使用 TextView 属性设置的颜色。

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/blue"/>
SpannableString spannable = new SpannableString(“Text styling”);
spannable.setSpan(
     new ForegroundColorSpan(Color.PINK), 
     0, 4, 
     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
myTextView.setText(spannable);

当使用 span 时,需要使用SpannedString, SpannableString 或 SpannableStringBuilder之一。 它们之间的区别在于text内容或markup是可改变的还是不可改变的,以及它们使用的内部结构:SpannedString 和 SpannableString 使用线性数组记录已添加的 span,而 SpannableStringBuilder 使用 区间树。

如何决定使用哪一个类:

  • 创建后 文本 和 span 不可变 –> SpannedString
  • 创建后文本不可变,仅需设置 少量的 span (<~ 10)? –> SpannableString
  • 创建后需设置 文本 和 span –> SpannableStringBuilder
  • 创建后需设置 大量的 span (>~ 10)? –> SpannableStringBuilder

Android framework在android.text.style包提供了20+的Span样式,通过2个维度可以对Span进行分类:

  • 基于Span是否改变text的外形还是改变text的尺寸或布局
  • 基于Span的作用范围是字符级别还是或段落级别

Span的实现原理是,Android framework定义了几个接口和抽象类,这些接口和抽象类有允许Span访问TextPaint或Canvas对象的方法,它们会在测量和渲染时被检查,达到改变文本样式的效果。

影响text外观的Span

这些Span可以影响text外观:文本或背景颜色、下划线、删除线等等,如下UML类图所示,类名所见即所得。

这些Span会触发文本重新绘制,而不会触发重新布局。这些 span 实现了 UpdateAppearance 且继承自 CharacterStyle。CharacterStyle的子类通过提供更新 TextPaint 的访问方法,定义了怎样绘制文本。

影响text尺寸或布局的Span

这些Span可以影响text的尺寸和布局,如文本绝对尺寸、相对尺寸、插入图片、上标、下标、字体、字体风格等,如下UML类图所示,类名所见即所得。这些Span都继承自MetricAffectingSpan。

影响文本字体大小的Span可能会使得text字符宽高变化,甚至多出来一行,其实现是通过监听,触发重新测量、进而重新计算布局,进而重新绘制。这些Span继承自MetricAffectingSpan类,这个抽象类通过提供对 TextPaint的访问,来影响文本测量,而 MetricAffectingSpan 继承自CharacterSpan,其子类在字符级别影响文本的外形。

字符级Span

抽象类CharacterStyle对文本产生的影响在字符级别,更新元素,如背景颜色、样式或大小,上面的影响text外观、影响text尺寸或布局的Span都是字符级的Span。 CharacterStyle主要就是一个抽象方法updateDrawState,影响绘制属性,总结下来就是,一支画笔走天下,什么效果都能渲染。 MetricAffectingSpan主要就是一个抽象方法updateMeasureState,影响测量,进而重新布局。

段落级Span

段落级别Span都实现了接口ParagraphStyle(空接口),这些Span可以更改整个文本块的对齐方式或者边距。继承自ParagraphStyle的Span必须作用于text整体,从第一个字符附加到单个段落的最后一个字符,否则Span不会被显示。 在 Android 中,段落是基于换行符 (\n) 定义的。

Framework中段落级的Span,如下UML类图所示,类名所见即所得。可以看到很多接口没有实现,系统是预留了很多能力的,方便自定义。

自定义Span

系统提供的Span样式虽多,但是未必有一款合你心意,自定义Span总是在所难免。在实现你自己的Span时,需要确定你的Span是会影响字符级别还是影响段落级别的文本,以及它是影响文本的布局还是影响文本的外观,据此选择需要扩展的基类和实现的接口。相应选择如下:

举个例子,你需要Span样式可以改变文本的大小和颜色。你可以扩展RelativeSizeSpan,由于 RelativeSizeSpan已经提供了updateDrawState和updateMeasureState回调,我们可以复写绘制状态回调并设置 TextPaint 的颜色。这只是一个自定义Span的例子而已,同样的效果你可以通过组合使用RelativeSizeSpan和ForegroundColorSpan来达成。

public class RelativeSizeColorSpan extends RelativeSizeSpan {
    private int color;
    public RelativeSizeColorSpan(float spanSize, int spanColor) {
        super(spanSize);
        color = spanColor;
    }
    @Override
    public void updateDrawState(TextPaint textPaint) {
        super.updateDrawState(textPaint);
        textPaint.setColor(color);
    }
}

Span使用最佳实践

基于使用场景,TextView#setText()方法有几种优化内存的方式。原理是,setText方法会copy一份text实例,在某些场景可以规避创建copy text实例。

text不变增加或移除Span

TextView#setText()因处理不同的Span有多个重载,例如,设置一个Spannable text:

textView.setText(spannableObject);

当调用setText()方法,TextView会copy Spannable作为SpannableString,并在内存中以CharSequence形态保存。这意味着text和Span是不可变的,当需要更新text和Span时,需要创建新的Spannable,并且调用setText()。 如果Span是可变的,使用setText(CharSequence text, TextView.BufferType type)更佳, 如下:

textView.setText(spannable, BufferType.SPANNABLE);
Spannable spannableText = (Spannable) textView.getText();
spannableText.setSpan(
     new ForegroundColorSpan(color),
     8, spannableText.getLength(),
     SPAN_INCLUSIVE_INCLUSIVE);

上例中,由于BufferType.SPANNABLE参数,setText方法创建了SpannableString(可变markup,不可变文本),再次更新Span时,可以获取TextView中的Spannable引用,而非再次创建新的Spannable实例,优化内存使用。 需要注意的是,此时需要主动调用invalidate() 或者requestLayout(),根据更新的Span是影响外观的,还是影响尺寸和布局的而定。

TextView多次设置text

一些场景,比如RecyclerView.ViewHolder,存在TextView复用,导致多次设置text。 通常不使用BufferType参数的情况下,每次设置文本,TextView都会copy一份实例,以CharSequence的形态存在内存中。也就是,每次设置新的文本,TextView都会创建新的实例。 通过实现自己的Spannable#Factory并重写newSpannable()可以控制这个过程,并避免多余实例的创建。范例如下:

Spannable.Factory spannableFactory = new Spannable.Factory(){
    @Override
    public Spannable newSpannable(CharSequence source) {
        return (Spannable) source;
    }
};

需要注意的是,必须使用textView.setText(spannableObject, BufferType.SPANNABLE)这种方式设置文本,否则就会抛出ClassCastException。 需要告诉TextView使用自定义的Spannable#Factory,如下:

textView.setSpannableFactory(spannableFactory);

在获得TextView引用之后需要立刻设置,如果在使用RecyclerView,应该在view第一次被inflate出来之后立刻设置Factory,避免绑定数据时TextView#setText()出现多余的实例创建。

改变Span属性

如果需要改变一个可变Span的内部属性,比如改变BulletSpan的颜色,避免多次重头调用setText()方法,最佳实现方式是,保存Span的引用,再需要更新Span属性时,通过引用改变属性,然后调用invalidate() 或者 requestLayout()方法。 BulletSpan颜色改变的范例如下:

public class MainActivity extends AppCompatActivity {

    private BulletPointSpan bulletSpan = new BulletPointSpan(Color.RED);

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        SpannableString spannable = new SpannableString("Text is spantastic");
        // setting the span to the bulletSpan field
        spannable.setSpan(bulletSpan, 0, 4, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
        styledText.setText(spannable);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                // change the color of our mutable span
                bulletSpan.setColor(Color.GRAY);
                // color won’t be changed until invalidate is called
                styledText.invalidate();
            }
        });
    }
}

Span支持绘制View

通过继承 ReplacementSpan 在 TextView 控件区域内画自己想画的东西。ReplacementSpan只有两个抽象方法需要我们重写:

/**
 * Returns the width of the span
 */
public abstract int getSize(Paint paint, CharSequence   text, int start, int end, Paint.FontMetricsInt fm);

/**
 * Draws the span into the canvas.
 */
public abstract void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint);

第一个方法getSize(),返回值就是Span替换文字后所占的宽度

第二个方法draw(),在TextView绘制时被调用,与此同时,会把canvas,text,paint以及一堆坐标传给我们,我们覆盖这个方法,就可以在特定位置画一些我们想画的东西了。

其实可以将TextView和ImageView的绘制在TextView中,可以更加方便地设置图片和文字的样式。也就是,可以整一个ViewSpan,传入外部的View,把这个View绘制在想要的位置。既然可以放一个View,那么为什么不直接放一个LinearLayout,绘制在TextView中,在LinearLayout中再放入View,这样的话,不是既可以设置View的样式,又可以控制View的位置。

public class MarkerViewSpan extends ReplacementSpan {

    protected View view;

    public MarkerViewSpan(View view ) {
        super();
        this.view = view;

        this.view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
    }

    @Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm) {

        int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
        int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
        view.measure(widthSpec, heightSpec);
        view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());

        if (fm != null) {
            int height = view.getMeasuredHeight();
            fm.ascent = fm.top = -height / 2;
            fm.descent = fm.bottom = height / 2;
        }
        return view.getRight();
    }

    @Override
    public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {

        Paint.FontMetricsInt fm = paint.getFontMetricsInt();
        int transY = (y + fm.descent + y + fm.ascent) / 2 - view.getMeasuredHeight() / 2;//计算y方向的位移

        canvas.save();
        canvas.translate(x, transY);
        view.draw(canvas);
        canvas.restore();
    }

}

上面代码中,我们同样继承了 ReplacementSpan ,通过构造方法传入了一个View,在 getSize() 方法中测量View的尺寸,设置 fm(Paint.FontMetricsInt)的 ascent 和 descent;在 draw() 方法中定位位置,调用 view.draw(canvas),绘制出 View 的视图。

下面看一下Android是如何绘制文字的。

Android中绘制文字是根据基线定位的。

draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint)

方法中的 x 和 y 就是图中绿点的 x和y的坐标,y就是基线位置。ascent 和 descent 的含义上面图片中有文字提示。

在 getSize() 方法中我们已经设置了 ascent 和 descent。我们需要定位View的位置。下面是通过中线(centerLine)位置计算基线 (baseLine),现在我们知道了基线位置 y,和 ascent 和 descent。可以计算出 centerLine 位置。

①centerLine作为ascentLine和descentLine的中间线

centerLineY = (ascentLineY + descentLineY)/2

<=> centerLineY = (ascent + baselineY + descent + baselineY)/2

<=> centerLineY = baselineY + (ascent + descent)/2

<=>baselineY = centerLineY - (ascent + descent)/2

∵ ascent = fontMetrics.ascent, descent = fontMetrics.descent

∴ baseLineY = centerLineY - (fontMetrics.ascent + fontMetrics.descent)/2

得出centerLinerY的位置:centerLineY = (ascent + baselineY + descent + baselineY)/2 = (fm.ascent + y + fm.descent + y ) / 2。

由于画笔在基线位置开始绘制,所以需要找到 View 的开始绘制位置,将画笔移到那个位置绘制View,再恢复。而 View 需要与文字居中对齐,所以,需要让中线位置再减去 view 高度的一半。这样就得出了画笔需要移动的位置:

int transY = (y + fm.descent + y + fm.ascent) / 2 - view.getMeasuredHeight() / 2;

Span转发手势

需要让Span支持点击事件处理,需要给TextView设置setMovementMethod。TextView的源码中:

@Override
public boolean onTouchEvent(MotionEvent event) {
// 略去无关代码
if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
            && mText instanceof Spannable && mLayout != null) {
        boolean handled = false;
    
        if (mMovement != null) {
            handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
        }
    
        final boolean textIsSelectable = isTextSelectable();
        if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
            // The LinkMovementMethod which should handle taps on links has not been installed
            // on non editable text that support text selection.
            // We reproduce its behavior here to open links for these.
            ClickableSpan[] links = ((Spannable) mText).getSpans(getSelectionStart(),
                    getSelectionEnd(), ClickableSpan.class);
    
            if (links.length > 0) {
                links[0].onClick(this);
                handled = true;
            }
        }
    }
// 略去无关代码
}

需要实现自定义点击事件处理机制,我们需要继承LinkMovementMethod,重写onTouchEvent方法。

private TouchableReplacementSpan touchedTarget;

@Override
public boolean onTouchEvent(final TextView textView, final Spannable spannable, MotionEvent event) {
    int action = event.getActionMasked();
    TouchableReplacementSpan touchableReplacementSpan = getTouchedSpan(textView, spannable, event);
    if(null!=touchableReplacementSpan){
        if (action == MotionEvent.ACTION_DOWN) {
            touchedTarget=touchableReplacementSpan;
        } else if(null!=touchedTarget&&touchedTarget!=touchableReplacementSpan){
            //When the finger keep moving. It may be changing the span.
            MotionEvent newEvent = MotionEvent.obtain(event);
            newEvent.setAction(MotionEvent.ACTION_CANCEL);
            touchedTarget.onTouchEvent(textView,newEvent);
            touchedTarget=touchableReplacementSpan;
        }
        touchableReplacementSpan.onTouchEvent(textView,event);
    } else if(action==MotionEvent.ACTION_UP||action==MotionEvent.ACTION_CANCEL){
        if(null!=touchedTarget){
            MotionEvent newEvent = MotionEvent.obtain(event);
            newEvent.setAction(MotionEvent.ACTION_CANCEL);
            touchedTarget.onTouchEvent(textView,newEvent);
            touchedTarget=null;
        }
    }
    return true;
}

/**
 * Return the touch offset position.
 * @param textView
 * @param event
 * @return
 */
private int getTouchedOffset(TextView textView, MotionEvent event){
    int x = (int) event.getX();
    int y = (int) event.getY();

    x -= textView.getTotalPaddingLeft();
    y -= textView.getTotalPaddingTop();

    x += textView.getScrollX();
    y += textView.getScrollY();

    final Layout layout = textView.getLayout();
    int line = layout.getLineForVertical(y);
    int off=0;
    try {
        off=layout.getOffsetForHorizontal(line, x*1f);
    } catch (IndexOutOfBoundsException e) {
    }
    return off;
}

/**
 * Getting the touched span object from the touch event.
 * @param widget
 * @param spannable
 * @param event
 * @return
 */
private TouchableReplacementSpan getTouchedSpan(TextView widget, Spannable spannable, MotionEvent event){
    int touchedOffset = getTouchedOffset(widget, event);
    TouchableReplacementSpan[] spans = spannable.getSpans(touchedOffset, touchedOffset, TouchableReplacementSpan.class);
    if (0 < spans.length){
        return spans[0];
    }
    return null;
}

然后在自定义的ReplacementSpan中包含的View,进行事件的分发给对应的View。

Span扩展文本动画

将所有字符转换为 replacement span 来支持动画。然后,我们将文本行为抽象为文本控制器,来完全操纵所有动画 span。

需要获得所有的字符我们需要覆盖AppCompatTextView的setText方法,然后在方法中创建新的字符串,并将它设置给AppCompatTextView。

@Override
public void setText(CharSequence text, BufferType type) {
    SpannableString newText = new SpannableString(text);
    for(int i=0;i<newText.length();i++){
        AnimationTextSpan animationTextSpan = new AnimationTextSpan(this,text,i,i+1);
        animationTextSpan.setAlpha(0f);
        newText.setSpan(animationTextSpan,i,i+1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
    }
    //Invoke The setText method.
    CharSequence oldText = getText();
    this.oldText = oldText;
    if(null!=oldText&&null!=newText){
        pendingFlag|=FLAG_TRANSITION;
    }
    //Setting the new text.
    super.setText(newText, BufferType.NORMAL);
    //This method will be invoked from the superclass.
    //So no matter if we initialize this field in this class. It will throw a null pointer exception.
    if(null==textController){
        textController=new DefaultTextController();
    }
    this.textController.attachToTextView(this);
    this.prepareAnimator();
}

然后覆盖AppCompatTextView的onDraw方法绘制对应的动画属性,因为再给AppCompatTextView设置新字符串时保留了旧的字符串,所以现在我们有了字符动画的初始和最后的状态。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    AbsTextController textController = getTextController();
    //Prepare the text controller
    if(0!=(pendingFlag&FLAG_PREPARE)&&null!=textController){
        pendingFlag^=FLAG_PREPARE;
        textController.prepare();
    }
    //Start the the text controller
    if(0!=(pendingFlag&FLAG_ANIMATOR)&&null!=textController){
        pendingFlag^=FLAG_ANIMATOR;
        textController.start();
    }
    //Start the text transform.
    if(0!=(pendingFlag&FLAG_TRANSITION)){
        pendingFlag^=FLAG_TRANSITION;
        if(null!=textTransition && null!= oldText){
            CharSequence newText = getText();
            textTransition.transform(this, (Spanned) newText, (Spanned) oldText);
        }
    }
    //Drawing the text transition.
    drawTransformText(canvas,textTransition);
    //Drawing the text controller.
    drawTextController(canvas, textController);
}

/**
 * Drawing the text transition.
 * @param canvas
 * @param textTransform
 */
private void drawTransformText(Canvas canvas,TextTransition textTransform) {
    if(null!=textTransform){
        canvas.save();
        int totalPaddingLeft = getTotalPaddingLeft();
        int totalPaddingTop = getTotalPaddingTop();
        canvas.translate(totalPaddingLeft,totalPaddingTop);
        TextPaint paint = getPaint();
        textTransform.onDraw(this,canvas,paint);
        canvas.restore();
    }
}

/**
 * Drawing the text controller.
 * @param canvas
 * @param textController
 */
private void drawTextController(Canvas canvas, AbsTextController textController) {
    if(null!=textController){
        canvas.save();
        canvas.translate(getPaddingLeft(),getPaddingTop());
        textController.onDraw(canvas);
        canvas.restore();
    }
}

AbsTextController是用于控制字符的属性变化,例如透明、位移、缩放、旋转等。TextTransition是用于将字符在新旧两种状态中做变化。

例如倒计时变化。

public interface TextTransition {

    /**
     * Transform the text form old text to new text.
     * @param textView the text view.
     * @param newText the new text spannable.
     * @param oldText the old text spannable.
     */
    void transform(TextView textView, Spanned newText, Spanned oldText);

    /**
     * Drawing something when the text transform from old text to new text.
     * @param textView
     * @param canvas
     * @param paint
     */
    void onDraw(@NonNull TextView textView, @NonNull Canvas canvas, @NonNull Paint paint);
}

public class CountDownTextTransform implements TextTransition {
    private AnimationTextSpan[] oldTextSpanArray;

    @Override
    public void transform(TextView textView, Spanned newText, Spanned oldText) {
        oldTextSpanArray = oldText.getSpans(0, oldText.length(), AnimationTextSpan.class);
        AnimationTextSpan[] newTextSpanArray = newText.getSpans(0, newText.length(), AnimationTextSpan.class);
        int length = Math.max(oldTextSpanArray.length, newTextSpanArray.length);
        for(int i=0;i<length;i++){
            AnimationTextSpan oldAnimationTextSpan=null;
            if(i < oldTextSpanArray.length){
                oldAnimationTextSpan = oldTextSpanArray[i];
            }
            AnimationTextSpan newAnimationTextSpan=null;
            if(i < newTextSpanArray.length){
                newAnimationTextSpan = newTextSpanArray[i];
            }
            if(null!=oldAnimationTextSpan&&null!=newAnimationTextSpan){
                if(oldAnimationTextSpan.getWord()!=newAnimationTextSpan.getWord()){
                    transformOldSpan(oldAnimationTextSpan);
                    transformNewSpan(newAnimationTextSpan);
                }
            } else if(null!=oldAnimationTextSpan){
                transformOldSpan(oldAnimationTextSpan);
            } else if(null!=newAnimationTextSpan){
                transformNewSpan(newAnimationTextSpan);
            }
        }
    }

    private void transformNewSpan(AnimationTextSpan newAnimationTextSpan) {
        RectF newBounds = newAnimationTextSpan.getBounds();
        newAnimationTextSpan.setTranslationY(-newBounds.height());
        newAnimationTextSpan.setClipRect(newBounds, Region.Op.INTERSECT);
        TextSpanPropertyAnimator textSpanPropertyAnimator = newAnimationTextSpan.propertyAnimator();
        textSpanPropertyAnimator.translationY(0);
    }

    private void transformOldSpan(AnimationTextSpan oldAnimationTextSpan) {
        RectF oldBounds = oldAnimationTextSpan.getBounds();
        oldAnimationTextSpan.setClipRect(oldBounds, Region.Op.INTERSECT);
        oldAnimationTextSpan.setTranslationY(0);
        TextSpanPropertyAnimator textSpanPropertyAnimator = oldAnimationTextSpan.propertyAnimator();
        textSpanPropertyAnimator.translationY(oldBounds.height());
    }

    @Override
    public void onDraw(@NonNull TextView textView,@NonNull Canvas canvas, @NonNull Paint paint) {
        if(null!=oldTextSpanArray){
            for(AnimationTextSpan animationTextSpan:oldTextSpanArray){
                animationTextSpan.drawText(canvas,paint);
            }
        }
    }
}

在transform方法的变换中只是获取我们在AppCompatTextView的setText方法中生成的AnimationTextSpan,然后根据需要变化的属性进行赋值。然后在onDraw方法中将AnimationTextSpan进行绘制,从而实现动画。

AnimationTextSpan其实是继承了ReplacementSpan的支持动画的Span。支持属性动画和对象动画。

1、使用了自定义TextPropertyHolder类来封装属性动画的各种属性,包括透明、位移、缩放、旋转。然后使用ValueAnimator来进行各种属性的变化处理并传递给AnimationTextSpan,最后AnimationTextSpan使用Canvas和Paint进行文字的绘制。

/**
 * Drawing the text information use all the fields inside.
 * This function actually support us to drawing the text outside by using the boundary of the span.
 * So no matter the span in the text view or not. we could draw this span anywhere.
 * This is for text transform or something like that.
 * @param canvas
 * @param paint
 */
public void drawText(@NonNull Canvas canvas, @NonNull Paint paint){
    if(!isWillNotDraw()) {
        canvas.save();
        //All the animation properties.
        float alpha = propertyHolder.getAlpha();
        float rotate = propertyHolder.getRotate();
        float scaleX = propertyHolder.getScaleX();
        float scaleY = propertyHolder.getScaleY();
        float translationX = propertyHolder.getTranslationX();
        float translationY = propertyHolder.getTranslationY();
        RectF bounds = propertyHolder.getBounds();
        //We are using canvas behavior to support all of the animation features.
        canvas.rotate(rotate,bounds.centerX(),bounds.centerY());
        canvas.scale(scaleX,scaleY,bounds.centerX(),bounds.centerY());
        paint.setAlpha(Math.round(0xFF*alpha));
        if(null!=clipRect&&null!=op){
            canvas.clipRect(clipRect, op);
        }
        Rect textBounds = getTextBounds();
        float offsetX=bounds.left;
        float offsetY=bounds.top-textBounds.top;
        canvas.drawText(text,start,end,translationX+offsetX,translationY+offsetY,paint);
        canvas.restore();
    }
}

2、使用ObjectAnimator来进行对象属性的变化处理,最后AnimationTextSpan使用Canvas和Paint进行文字的绘制。

override fun startAnimator(animationTextSpanList:List<AnimationTextSpan>) {
    animationTextSpanList.forEachIndexed{ index,animationTextSpan->
        val bounds = animationTextSpan.bounds
        val left=bounds.left
        val frame1= Keyframe.ofFloat(0f,-bounds.width())
        val frame2= Keyframe.ofFloat(0.2f,left-60)
        val frame3= Keyframe.ofFloat(0.8f,left+60)
        val frame4= Keyframe.ofFloat(1.0f, left+width)

        val objectAnimator = animationTextSpan.objectAnimator()
        objectAnimator.setValues(PropertyValuesHolder.ofKeyframe("x", frame1, frame2, frame3,frame4))
        objectAnimator.interpolator= LinearInterpolator()
        objectAnimator.duration=4000
        objectAnimator.startDelay=(animationTextSpanList.size-index)*100L
        objectAnimator.repeatCount=ValueAnimator.INFINITE
        objectAnimator.repeatMode= ValueAnimator.RESTART
        objectAnimator.start()
    }
}