自定义实现垂直滚动的TextView

需求

  • 当TextView限制最大行数的时候,文本内容超过最大行数可自动实现文本内容向上滚动
  • 随着TextView的文本内容的改变,可自动计算换行并实时的向上滚动
  • 文字向上滚动后可向下滚动回到正确的水平位置

自定义方法

  • 自定义一个View,继承自View,定重写里面的onDraw方法
  • 文字的滚动是用Canvas对象的drawText方法去实现的
public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint) {
        native_drawText(mNativeCanvasWrapper, text, 0, text.length(), x, y, paint.mBidiFlags,
                paint.getNativeInstance(), paint.mNativeTypeface);
    }

通过控制y参数可实现文字不同的垂直距离,这里的x,y并不代表默认横向坐标为0,纵向坐标为0的坐标,具体详解我觉得这篇博客解释的比较清楚,我们主要关注的是参数y的控制,y其实就是text的baseline,这里还需要解释text的杰哥基准线:


image.png

ascent:该距离是从所绘字符的baseline之上至该字符所绘制的最高点。这个距离是系统推荐。
descent:该距离是从所绘字符的baseline之下至该字符所绘制的最低点。这个距离是系统推荐的。
top:该距离是从所绘字符的baseline之上至可绘制区域的最高点。
bottom:该距离是从所绘字符的baseline之下至可绘制区域的最低点。
leading:为文本的线之间添加额外的空间,这是官方文档直译,debug时发现一般都为0.0,该值也是系统推荐的。
特别注意: ascent和top都是负值,而descent和bottom:都是正值。

由于text的baseline比较难计算,所以我们大约取y = bottom - top的值,这么坐位baseline的值不是很精确,但是用在此自定义控件上文字的大小间距恰好合适,在其他场景可能还是需要精确的去计算baseline的值

动画效果实现

  • 通过循环触发执行onDraw方法来实现文字的上下滑动,当然在每次触发onDraw之前首先要计算文字的baseline的值
  • 通过设置Paint的alpha的值来控制透明度,alpha的值的变化要和文字baseline的变化保持同步,因为文字上下滑动和文字的透明度要做成一个统一的动画效果
  • 文字的换行,首先用measureText来测量每一个字的宽度,然后持续累加,直到累加宽度超过一行的最大限制长度之后就追加一个换行符号,当然我们是用一个List作为容器来容纳文本内容,一行文本就是list的一个item所以不用追加换行符号,直接添加list的item
  • 在实现文字上下滑动以及透明度变化的时候遇到一个问题,就是上一次的滑动刚刚滑到一半,文字的baseline和透明度已经改变到一半了,这时候又有新的文本追加进来,那么新的文本会导致一次新的滑动动画和文字透明度改变动画会和之前的重叠,造成上一次的滑动效果被中断,文字重新从初始值开始滑动,所以会看到文字滑动到一半又回到初始位置重新开始滑动,那么如果一直不断的有文字追加进来会导致文字滑动反复的中断开始,这种效果当然不是我们想要的,我们想要的就是文字滑动到一半了,那么已经滑动的文字保持当前的状态,新追加进来的问题从初始值开始滑动,滑动到一半的文字从之前的状态继续滑动,所以就需要记录文字的滑动间距,透明度等信息并保存下来

代码实现

public class AutoScrollTextView extends View {

    public interface OnTextChangedListener {
        void onTextChanged(String text);
    }

    private class TextStyle {
        int alpha;
        float y;
        String text;

        TextStyle(String text, int alpha, float y) {
            this.text = text;
            this.alpha = alpha;
            this.y = y;
        }
    }

    public static final int SCROLL_UP = 0, SCROLL_DOWN = 1;

    private List<TextStyle> textRows = new ArrayList<>();

    private OnTextChangedListener onTextChangedListener;

    private Paint textPaint;

    /**
     * 标题内容
     */
    private String title;

    /**
     * 是否是标题模式
     */
    private boolean setTitle;

    /**
     * 当前的文本内容是否正在滚动
     */
    private boolean scrolling;

    /**
     * 文字滚动方向,支持上下滚动
     */
    private int scrollDirect;

    /**
     * 每行的最大宽度
     */
    private float lineMaxWidth;

    /**
     * 最大行数
     */
    private int maxLineCount;

    /**
     * 每行的高度,此值是根据文字的大小自动去测量出来的
     */
    private float lineHeight;

    public AutoScrollTextView(Context context) {
        super(context);
        init();
    }

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

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

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

    private void init() {
        textPaint = createTextPaint(255);
        lineMaxWidth = textPaint.measureText("一二三四五六七八九十"); // 默认一行最大长度为10个汉字的长度
        maxLineCount = 4;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
        float x;
        float y = fontMetrics.bottom - fontMetrics.top;
        lineHeight = y;
        if (setTitle) {
            x = getWidth() / 2 - textPaint.measureText(title) / 2;
            canvas.drawText(title, x, y, textPaint);
        } else {
            synchronized (this) {
                if (textRows.isEmpty()) {
                    return;
                }
                scrolling = true;
                x = getWidth() / 2 - textPaint.measureText(textRows.get(0).text) / 2;
                if (textRows.size() <= 2) {
                    for (int index = 0;index < 2 && index < textRows.size();index++) {
                        TextStyle textStyle = textRows.get(index);
                        textPaint.setAlpha(textStyle.alpha);
                        canvas.drawText(textStyle.text, x, textStyle.y, textPaint);
                    }
                } else {
                    boolean draw = false;
                    for (int row = 0;row < textRows.size();row++) {
                        TextStyle textStyle = textRows.get(row);
                        textPaint.setAlpha(textStyle.alpha);
                        canvas.drawText(textStyle.text, x, textStyle.y, textPaint);
                        if (textStyle.alpha < 255) {
                            textStyle.alpha += 51;
                            draw = true;
                        }
                        if (textRows.size() > 2) {
                            if (scrollDirect == SCROLL_UP) {
                                // 此处的9.0f的值是由255/51得来的,要保证文字透明度的变化速度和文字滚动的速度要保持一致
                                // 否则可能造成透明度已经变化完了,文字还在滚动或者透明度还没变化完成,但是文字已经不滚动了
                                textStyle.y = textStyle.y - (lineHeight / 9.0f);
                            } else {
                                if (textStyle.y < lineHeight + lineHeight * row) {
                                    textStyle.y = textStyle.y + (lineHeight / 9.0f);
                                    draw = true;
                                }
                            }
                        }
                    }
                    if (draw) {
                        postInvalidateDelayed(50);
                    } else {
                        scrolling = false;
                    }
                }
            }
        }
    }

    private Paint createTextPaint(int a) {
        Paint textPaint = new Paint();
        textPaint.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 15, getContext().getResources().getDisplayMetrics()));
        textPaint.setColor(getContext().getColor(R.color.color_999999));
        textPaint.setAlpha(a);
        return textPaint;
    }

    public void resetText() {
        synchronized (this) {
            textRows.clear();
        }
    }

    public void formatText() {
        scrollDirect = SCROLL_DOWN;
        StringBuffer stringBuffer = new StringBuffer("\n");
        synchronized (this) {
            for (int i = 0;i < textRows.size();i++) {
                TextStyle textStyle = textRows.get(i);
                if (textStyle != null) {
                    textStyle.alpha = 255;
//                    textStyle.y = 45 + 45 * i;
                    stringBuffer.append(textStyle.text + "\n");
                }
            }
        }
        postInvalidateDelayed(100);
        LogUtil.i("formatText:" + stringBuffer.toString());
    }

    public void appendText(String text) {
        setTitle = false;
        scrollDirect = SCROLL_UP;
        synchronized (this) {
            if (textRows.size() > maxLineCount) {
                return;
            }
            if (text.length() <= 10) {
                if (textRows.isEmpty()) {
                    textRows.add(new TextStyle(text, 255, lineHeight + lineHeight * textRows.size()));
                } else {
                    TextStyle pre = textRows.get(textRows.size() - 1);
                    textRows.set(textRows.size() - 1, new TextStyle(text, pre.alpha, pre.y));
                }
            } else {
                List<String> list = new ArrayList<>();
                StringBuffer stringBuffer = new StringBuffer();
                float curWidth = 0;
                for (int index = 0;index < text.length();index++) {
                    char c = text.charAt(index);
                    curWidth += textPaint.measureText(String.valueOf(c));
                    if (curWidth <= lineMaxWidth) {
                        stringBuffer.append(c);
                    } else {
                        if (list.size() < maxLineCount) {
                            list.add(stringBuffer.toString());
                            curWidth = 0;
                            index--;
                            stringBuffer.delete(0, stringBuffer.length());
                        } else {
                            break;
                        }
                    }
                }
                if (!TextUtils.isEmpty(stringBuffer.toString()) && list.size() < maxLineCount) {
                    list.add(stringBuffer.toString());
                }
                if (textRows.isEmpty()) {
                    for (int i = 0;i < list.size();i++) {
                        if (i < 2) {
                            textRows.add(new TextStyle(list.get(i), 255, lineHeight + lineHeight * i));
                        } else {
                            textRows.add(new TextStyle(list.get(i), 0, lineHeight + lineHeight * i));
                        }
                    }
                } else {
                    for (int i = 0;i < list.size();i++) {
                        if (textRows.size() > i) {
                            TextStyle pre = textRows.get(i);
                            textRows.set(i, new TextStyle(list.get(i), pre.alpha, pre.y));
                        } else {
                            TextStyle pre = textRows.get(textRows.size() - 1);
                            if (i < 2) {
                                textRows.add(new TextStyle(list.get(i), 255, pre.y + lineHeight));
                            } else {
                                textRows.add(new TextStyle(list.get(i), 0, pre.y + lineHeight));
                            }
                        }
                    }
                }
            }
            if (!scrolling) {
                invalidate();
            }
        }
        textChanged();
    }

    public void setTextColor(int corlor) {
        textPaint.setColor(corlor);
        invalidate();
    }

    public void setTitle(int resId) {
        this.title = getContext().getString(resId);
        setTitle = true;
        invalidate();
    }

    public void setOnTextChangedListener(OnTextChangedListener onTextChangedListener) {
        this.onTextChangedListener = onTextChangedListener;
    }

    private void textChanged() {
        if (onTextChangedListener != null) {
            onTextChangedListener.onTextChanged(getText());
        }
    }

    public String getText() {
        StringBuffer allText = new StringBuffer();
        for (TextStyle textStyle : textRows) {
            allText.append(textStyle.text);
        }
        return allText.toString();
    }

    public int getScrollDirect() {
        return scrollDirect;
    }

    public void setScrollDirect(int scrollDirect) {
        this.scrollDirect = scrollDirect;
    }

    public float getLineMaxWidth() {
        return lineMaxWidth;
    }

    public void setLineMaxWidth(float lineMaxWidth) {
        this.lineMaxWidth = lineMaxWidth;
    }

    public int getMaxLineCount() {
        return maxLineCount;
    }

    public void setMaxLineCount(int maxLineCount) {
        this.maxLineCount = maxLineCount;
    }

    public boolean isScrolling() {
        return scrolling;
    }
}

代码还可以重构的更加简洁,但是这边主要是为了做demo演示,所以就满看下实现的原理就好了

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容