7. Android AI大模型 文本打字机效果 流畅光标追踪与智能滚动

大模型最近很火,deepSeek, 豆包,文心一言,腾讯元宝,阿里的通义千问,讯飞的星火,在产生文本的时候,文字是逐字显示出来,类似于打字机的效果,今天我就做了一个

1.效果图

打字机效果,流畅光标追踪,样式自定义,自适应宽高滚动,打字音效

2.功能需求描述

2.1 大模型打字机效果: 文字逐字显示出来效果

2.2 有文本输入的光标效果

2.3 支持实时的文字样式,比如文字颜色,换行,加粗

2.4 支持打字音效

2.5 支持高度自适应滚动

3.设计思路

分为5步骤

第一步: 简单的打字机效果,通过SpannableStringBuilder

第二步: 添加光标效果 EditText

第三步: 样式的控制 setSpan

第四 步:打字音效 medai

第五 步:自适应滚动 scrolview

3.1 简单的打字机效果,通过SpannableStringBuilder

java 复制代码
private final SpannableStringBuilder ssb = new SpannableStringBuilder();

setText(ssb);



private void startTyping() {
    isTyping = true;
    handler.post(typeRunnable);
}


private final Runnable typeRunnable = new Runnable() {
    @Override
    public void run() {
        typeNextCharacter();
    }
};

1.使用SpannableStringBuilder动态构建富文本

  1. 通过Handler实现字符逐个显示动画

3.2 添加光标效果 EditText (也可以自己进行绘制)

请求焦点,设置光标的位置

scss 复制代码
setSelection(ssb.length()); // 设置文本光标的移动的位置
scss 复制代码
// 设置焦点相关属性
setFocusable(true);
setFocusableInTouchMode(true);
  1. 光标效果
    • 实心竖线光标
    • 500ms闪烁间隔
    • 自动跟随文本末尾

问题: 光标移动,但是没有跟随文字移动,也换行了!

3.3 样式的控制 setSpan

核心:计算样式的开始位置和结束的位置, 用了一个队列存储数据, 从队列取数据,计算样式

swift 复制代码
private final Deque<CharItem> charQueue = new ArrayDeque<>();
ini 复制代码
private void parseMarkdown(String text) {
    int pos = 0;
    int length = text.length();

    while (pos < length) {
        char c = text.charAt(pos);

        // 处理标题
        if (c == '#' && (pos == 0 || (pos > 0 && text.charAt(pos - 1) == '\n'))) {
            int headerLevel = 0;
            int startPos = pos;
            while (pos < length && text.charAt(pos) == '#') {
                headerLevel++;
                pos++;
            }

            if (pos < length && Character.isWhitespace(text.charAt(pos))) {
                currentTitleLevel = Math.min(headerLevel, 2); // 只支持1-2级标题
                pos++; // 跳过空格
                continue;
            } else {
                // 如果不是标题,回退并作为普通字符处理
                pos = startPos;
            }
        }

        // 处理加粗
        if (c == '*' && pos + 1 < length && text.charAt(pos + 1) == '*') {
            charQueue.add(new CharItem("", 0, false, true)); // 标记样式变化
            isBold = !isBold;
            pos += 2;
            continue;
        }

        // 处理换行
        if (c == '\n') {
            charQueue.add(new CharItem("\n", currentTitleLevel, isBold, false));
            currentTitleLevel = 0; // 重置标题
            // 注意:不再重置加粗,允许跨行加粗
            pos++;
            continue;
        }

        // 普通字符
        charQueue.add(new CharItem(String.valueOf(c), currentTitleLevel, isBold, false));
        pos++;
    }
}
ini 复制代码
private void typeNextCharacter() {
    if (charQueue.isEmpty()) {
        Log.d(TAG, "Typing completed");
        isTyping = false;
        return;
    }

    CharItem item = charQueue.poll();
    int start = ssb.length();

    // 记录之前的文本长度
    lastTextLength = ssb.length();

    // 只处理实际内容字符(跳过纯样式标记)
    if (!item.isStyleChange) {
        ssb.append(item.text);

        // 应用标题样式
        if (item.titleLevel > 0) {
            float textSize = getTextSize();
            float headerSize = textSize * (item.titleLevel == 1 ? 1.8f : 1.4f);
            int spanStart = start;
            int spanEnd = ssb.length();



            // 确保跨度有效
            if (spanStart < spanEnd) {
                ssb.setSpan(new AbsoluteSizeSpan((int) headerSize, true),
                        spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

                // 一级标题加粗
                if (item.titleLevel == 1) {
                    Log.e(TAG, "标题 spanStart: " + spanStart+", spanEnd:"+spanEnd);
                    ssb.setSpan(new StyleSpan(android.graphics.Typeface.BOLD),
                            spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }
        }

        // 应用加粗样式
        if (item.isBold) {
            int spanStart = start;
            int spanEnd = ssb.length();

            // 确保跨度有效
            if (spanStart < spanEnd) {
                Log.e(TAG, "加粗 spanStart: " + spanStart+", spanEnd:"+spanEnd);
                ssb.setSpan(new StyleSpan(android.graphics.Typeface.BOLD),
                        spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }

        setText(ssb);
    }

    // 更新光标位置为文本末尾
    cursorPosition = ssb.length();

    // 确保光标可见
    showCursor = true;
    invalidate();

    // 继续下一个字符
    if (!charQueue.isEmpty()) {
        handler.postDelayed(typeRunnable, CHAR_DELAY);
    } else {
        isTyping = false;
    }
}
  1. Markdown支持

    • 标题 :支持 # H1## H2
    • 加粗 :支持 **加粗文本**
    • 换行 :支持 \n 换行
    • 自动重置样式(标题在换行后重置,加粗需要显式关闭)

3.4 打字音效 MediaPlay,或者SoundPool

智能播放策略

arduino 复制代码
// 只在有效字符时播放(跳过换行符等)
if (!item.isStyleChange && !"\n ".contains(item.text)) {
    soundEffectManager.play();
}

3.5 :自适应滚动 Scrollview

参考这个方案

4. Android 用户狂赞的UI特效!揭秘折叠卡片+流光动画的终极实现方案高仿:来自鸿蒙智行,华为问界的智仓系统,语 - 掘金

4.架构图

5.总结

主要涉及样式的计算,其他都是比较简单!

  1. 样式与内容分离

    arduino 复制代码
    // 示例:样式解析结果存储
    class CharItem {
        String text;      // 原始字符
        int titleLevel;   // 0=普通, 1=H1, 2=H2
        boolean isBold;   // 是否加粗
    }
  2. 动态Span应用

    arduino 复制代码
    // 在渲染时动态应用样式
    if (item.titleLevel > 0) {
        ssb.setSpan(new AbsoluteSizeSpan(headerSize), start, end, SPAN_EXCLUSIVE);
    }
  3. 样式解析器

    • Markdown解析 :识别 #**\n 等符号

    • 样式映射

      • # 标题AbsoluteSizeSpan + StyleSpan(BOLD)
      • **加粗**StyleSpan(BOLD)
      • \n → 重置标题级别

6.源码

ini 复制代码
public class TypewriterTextView extends AppCompatTextView {

    private static final String TAG = "TypewriterTextView";
    private static final int CURSOR_BLINK_DELAY = 500;
    private static final int CHAR_DELAY = 30;
    private static final int DEFAULT_CURSOR_COLOR = 0xFF00FF00; // 明亮的绿色

    private final Deque<CharItem> charQueue = new ArrayDeque<>();
    private final SpannableStringBuilder ssb = new SpannableStringBuilder();
    private final Handler handler = new Handler(Looper.getMainLooper());
    private final Paint cursorPaint = new Paint();

    private boolean showCursor = true;
    private boolean isTyping = false;
    private int currentTitleLevel = 0;
    private boolean isBold = false;
    private int cursorPosition = 0; // 重命名为cursorPosition更清晰
    private int cursorColor = DEFAULT_CURSOR_COLOR;
    private float cursorWidth = 3f;
    private boolean isCursorActive = true;
    private boolean layoutFinished = false;
    private int lastTextLength = 0; // 跟踪上一次文本长度

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

    public TypewriterTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

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

    private void init() {
        // 设置光标样式
        cursorPaint.setColor(cursorColor);
        cursorPaint.setStrokeWidth(dpToPx(cursorWidth));

        // 确保视图准备好后再开始光标闪烁
        getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                getViewTreeObserver().removeOnGlobalLayoutListener(this);
                layoutFinished = true;
                startCursorBlink();
            }
        });

        // 设置焦点相关属性
        setFocusable(true);
        setFocusableInTouchMode(true);

        // 初始文本位置
        cursorPosition = 0;
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        // 安全地请求焦点
        post(() -> {
            if (isAttachedToWindow()) {
                requestFocus();
            }
        });
    }

    public void appendStreamText(String text) {
        if (text == null || text.isEmpty()) return;

        Log.d(TAG, "Appending text: " + text);
        parseMarkdown(text);

        // 如果没有在打字,启动打字动画
        if (!isTyping) {
            Log.d(TAG, "Starting typing animation");
            startTyping();
        }

        // 确保光标可见
        showCursor = true;
        invalidate();
    }

    private void startTyping() {
        isTyping = true;
        handler.post(typeRunnable);
    }

    private final Runnable typeRunnable = new Runnable() {
        @Override
        public void run() {
            typeNextCharacter();
        }
    };

    private void parseMarkdown(String text) {
        int pos = 0;
        int length = text.length();

        while (pos < length) {
            char c = text.charAt(pos);

            // 处理标题
            if (c == '#' && (pos == 0 || (pos > 0 && text.charAt(pos - 1) == '\n'))) {
                int headerLevel = 0;
                int startPos = pos;
                while (pos < length && text.charAt(pos) == '#') {
                    headerLevel++;
                    pos++;
                }

                if (pos < length && Character.isWhitespace(text.charAt(pos))) {
                    currentTitleLevel = Math.min(headerLevel, 2); // 只支持1-2级标题
                    pos++; // 跳过空格
                    continue;
                } else {
                    // 如果不是标题,回退并作为普通字符处理
                    pos = startPos;
                }
            }

            // 处理加粗
            if (c == '*' && pos + 1 < length && text.charAt(pos + 1) == '*') {
                charQueue.add(new CharItem("", 0, false, true)); // 标记样式变化
                isBold = !isBold;
                pos += 2;
                continue;
            }

            // 处理换行
            if (c == '\n') {
                charQueue.add(new CharItem("\n", currentTitleLevel, isBold, false));
                currentTitleLevel = 0; // 重置标题
                // 注意:不再重置加粗,允许跨行加粗
                pos++;
                continue;
            }

            // 普通字符
            charQueue.add(new CharItem(String.valueOf(c), currentTitleLevel, isBold, false));
            pos++;
        }
    }

    private void typeNextCharacter() {
        if (charQueue.isEmpty()) {
            Log.d(TAG, "Typing completed");
            isTyping = false;
            return;
        }

        CharItem item = charQueue.poll();
        int start = ssb.length();

        // 记录之前的文本长度
        lastTextLength = ssb.length();

        // 只处理实际内容字符(跳过纯样式标记)
        if (!item.isStyleChange) {
            ssb.append(item.text);

            // 应用标题样式
            if (item.titleLevel > 0) {
                float textSize = getTextSize();
                float headerSize = textSize * (item.titleLevel == 1 ? 1.8f : 1.4f);
                int spanStart = start;
                int spanEnd = ssb.length();



                // 确保跨度有效
                if (spanStart < spanEnd) {
                    ssb.setSpan(new AbsoluteSizeSpan((int) headerSize, true),
                            spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

                    // 一级标题加粗
                    if (item.titleLevel == 1) {
                        Log.e(TAG, "标题 spanStart: " + spanStart+", spanEnd:"+spanEnd);
                        ssb.setSpan(new StyleSpan(android.graphics.Typeface.BOLD),
                                spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                    }
                }
            }

            // 应用加粗样式
            if (item.isBold) {
                int spanStart = start;
                int spanEnd = ssb.length();

                // 确保跨度有效
                if (spanStart < spanEnd) {
                    Log.e(TAG, "加粗 spanStart: " + spanStart+", spanEnd:"+spanEnd);
                    ssb.setSpan(new StyleSpan(android.graphics.Typeface.BOLD),
                            spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }

            setText(ssb);
        }

        // 更新光标位置为文本末尾
        cursorPosition = ssb.length();

        // 确保光标可见
        showCursor = true;
        invalidate();

        // 继续下一个字符
        if (!charQueue.isEmpty()) {
            handler.postDelayed(typeRunnable, CHAR_DELAY);
        } else {
            isTyping = false;
        }
    }

    // 光标绘制
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (isCursorActive && showCursor && layoutFinished) {
            float x = getCursorX();
            if (x >= 0) {
                float baseline = getBaseline();
                float bottom = baseline + getLineHeight();
                canvas.drawLine(x, baseline, x, bottom, cursorPaint);
            }
        }
    }

    private float getCursorX() {
        if (getLayout() == null || cursorPosition > getLayout().getText().length()) {
            return getPaddingStart();
        }

        try {
            int line = getLayout().getLineForOffset(cursorPosition);
            return getLayout().getPrimaryHorizontal(cursorPosition);
        } catch (Exception e) {
            Log.e(TAG, "Error getting cursor position: " + e.getMessage());
            return getPaddingStart();
        }
    }

    private void startCursorBlink() {
        handler.post(cursorRunnable);
    }

    private final Runnable cursorRunnable = new Runnable() {
        @Override
        public void run() {
            if (isCursorActive && layoutFinished) {
                showCursor = !showCursor;
                invalidate();
            }
            handler.postDelayed(this, CURSOR_BLINK_DELAY);
        }
    };

    private float dpToPx(float dp) {
        return dp * getResources().getDisplayMetrics().density;
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        handler.removeCallbacks(typeRunnable);
        handler.removeCallbacks(cursorRunnable);
    }

    // 设置光标颜色
    public void setCursorColor(int color) {
        cursorColor = color;
        cursorPaint.setColor(color);
        invalidate();
    }

    // 设置光标宽度 (dp)
    public void setCursorWidth(float widthDp) {
        cursorWidth = widthDp;
        cursorPaint.setStrokeWidth(dpToPx(widthDp));
        invalidate();
    }

    // 启用/禁用光标
    public void setCursorActive(boolean active) {
        isCursorActive = active;
        showCursor = active;
        invalidate();
    }

    // 清除所有内容并重置状态
    public void clear() {
        ssb.clear();
        charQueue.clear();
        setText("");
        cursorPosition = 0;
        currentTitleLevel = 0;
        isBold = false;
        isTyping = false;
        handler.removeCallbacks(typeRunnable);
        showCursor = true;
        invalidate();
    }

    // 字符项封装类
    private static class CharItem {
        final String text;
        final int titleLevel; // 0=普通, 1=H1, 2=H2
        final boolean isBold;
        final boolean isStyleChange; // 仅样式变化无内容

        CharItem(String text, int titleLevel, boolean isBold, boolean isStyleChange) {
            this.text = text;
            this.titleLevel = titleLevel;
            this.isBold = isBold;
            this.isStyleChange = isStyleChange;
        }
    }
}

项目的地址:github.com/pengcaihua1...

相关推荐
程序视点3 小时前
IObit Uninstaller Pro专业卸载,免激活版本,卸载清理注册表,彻底告别软件残留
前端·windows·后端
前端程序媛-Tian3 小时前
【dropdown组件填坑指南】—怎么实现下拉框的位置计算
前端·javascript·vue
嘉琪0013 小时前
实现视频实时马赛克
linux·前端·javascript
烛阴4 小时前
Smoothstep
前端·webgl
若梦plus4 小时前
Eslint中微内核&插件化思想的应用
前端·eslint
爱分享的程序员4 小时前
前端面试专栏-前沿技术:30.跨端开发技术(React Native、Flutter)
前端·javascript·面试
超级土豆粉4 小时前
Taro 位置相关 API 介绍
前端·javascript·react.js·taro
若梦plus4 小时前
Webpack中微内核&插件化思想的应用
前端·webpack
若梦plus4 小时前
微内核&插件化设计思想
前端
柯北(jvxiao)4 小时前
搞前端还有出路吗?如果有,在哪里?
前端·程序人生