大模型最近很火,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
动态构建富文本
- 通过Handler实现字符逐个显示动画
3.2 添加光标效果 EditText (也可以自己进行绘制)
请求焦点,设置光标的位置
scss
setSelection(ssb.length()); // 设置文本光标的移动的位置
scss
// 设置焦点相关属性
setFocusable(true);
setFocusableInTouchMode(true);
- 光标效果 :
- 实心竖线光标
- 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;
}
}
-
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.总结
主要涉及样式的计算,其他都是比较简单!
-
样式与内容分离
arduino// 示例:样式解析结果存储 class CharItem { String text; // 原始字符 int titleLevel; // 0=普通, 1=H1, 2=H2 boolean isBold; // 是否加粗 }
-
动态Span应用
arduino// 在渲染时动态应用样式 if (item.titleLevel > 0) { ssb.setSpan(new AbsoluteSizeSpan(headerSize), start, end, SPAN_EXCLUSIVE); }
-
样式解析器
-
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;
}
}
}