Android使用Kotlin协程+Flow实现打字机效果
1.前言:
最近在开发Ai智能问答对话项目时,需要实现一个打字机效果,于是使用Kotlin协程+Flow实现了这个效果,感觉还挺不错的,直接上代码.
2.简介:
Android 打字机效果是一种模拟传统打字机逐字符输出文本的 UI 动效,通过文字逐个显示 + 光标动态闪烁的组合,营造出文本 “实时输入” 的视觉体验,常用于 App 欢迎页、剧情对话、数据加载提示等场景,能提升用户注意力与交互趣味性。
3. 核心实现逻辑
其本质是通过 “文本分段更新” 和 “光标周期性重绘” 实现,主流技术方案基于 Kotlin 协程+Flow
或 Handler
完成时序控制,核心步骤如下:
- 文本拆分:将完整目标文本按字符 / 词组拆分,规划逐段显示的顺序。
- 时序控制:通过协程
delay()
或Handler.postDelayed()
控制每段文本的显示间隔(即 “打字速度”)。 - 文本更新:每隔指定时间,更新 TextView 显示的文本(从 “空” 逐步拼接至完整文本)。
- 光标绘制:在文本末尾绘制竖线 / 方块光标,通过周期性切换 “显示 / 隐藏” 状态(即 “光标闪烁速度”)模拟输入光标效果。
- 状态管理:处理 “暂停 / 继续 / 重置” 等交互,以及页面销毁、配置变更(如屏幕旋转)时的状态保存与恢复。
4. 核心功能特性
标准打字机效果组件通常包含以下可配置 / 交互能力:
基础配置:自定义打字速度(毫秒 / 字符)、光标闪烁速度、是否显示光标。
核心交互
:
- 启动动画(
setTextWithAnimation()
):传入目标文本,自动开始逐字符显示。 - 暂停 / 继续(
pauseAnimation()
/resumeAnimation()
):支持中途暂停与断点续播。 - 重置(
resetAnimation()
):清空文本与状态,恢复初始状态。
- 启动动画(
状态安全
:
- 页面销毁时自动取消协程(
onDetachedFromWindow()
),避免内存泄漏。 - 配置变更时保存状态(
onSaveInstanceState()
),恢复后可续播动画。
- 页面销毁时自动取消协程(
5. 典型应用场景
- 欢迎页 / 引导页:逐字显示 App 介绍、slogan,引导用户注意力。
- 剧情类 App(如小说、漫画):模拟对话气泡 “实时输入”,增强沉浸感。
- 数据加载提示:替代传统 “Loading”,用 “正在获取数据…” 逐字显示提升等待体验。
- 教学类 App:逐字显示知识点,引导用户逐句阅读,提升信息接收效率。
6.思维导图:
7.自定义打字机效果TextVIew:
package com.example.typewritertextviewdemo
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.os.Parcel
import android.os.Parcelable
import android.text.TextPaint
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.util.*
import kotlin.math.min
/**
* @author: njb
* @date: 2025/8/8 19:18
* @desc: 描述
*/
class TypewriterTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
// 打字速度(毫秒/字符)
private var typingSpeed = 80L
// 光标闪烁速度(毫秒)
private var cursorBlinkSpeed = 500L
// 是否显示光标
private var showCursor = true
// 是否正在打字
private var isTyping = false
// 当前显示的文本
private var currentText = ""
// 完整文本
private var fullText = ""
// 光标位置
private var cursorPosition = 0
// 光标是否可见
private var cursorVisible = true
// 协程任务
private var typingJob: Job? = null
private var cursorJob: Job? = null
// 光标绘制相关
private val cursorPaint = Paint().apply {
color = currentTextColor
style = Paint.Style.FILL
strokeWidth = 4f
}
// 文本绘制相关
private val textPaint = TextPaint().apply {
color = currentTextColor
textSize = textSize
typeface = typeface
}
init {
// 从XML属性读取配置
context.obtainStyledAttributes(attrs, R.styleable.TypewriterTextView).apply {
typingSpeed = getInt(R.styleable.TypewriterTextView_typingSpeed, 80).toLong()
cursorBlinkSpeed = getInt(R.styleable.TypewriterTextView_cursorBlinkSpeed, 500).toLong()
showCursor = getBoolean(R.styleable.TypewriterTextView_showCursor, true)
// 如果设置了初始文本,立即开始打字
val text = getString(R.styleable.TypewriterTextView_typewriterText)
if (!text.isNullOrEmpty()) {
setTextWithAnimation(text)
}
recycle()
}
}
/**
* 设置打字机文本并开始动画
*/
fun setTextWithAnimation(text: String) {
cancelJobs()
fullText = text
currentText = ""
cursorPosition = 0
isTyping = true
// 开始打字效果
typingJob = getLifecycleScope().launch {
flow {
fullText.forEachIndexed { index, char ->
delay(typingSpeed)
currentText = fullText.substring(0, index + 1)
cursorPosition = currentText.length
emit(currentText)
}
}.collect {
setText(it)
// 请求重绘以更新光标
invalidate()
}
// 打字完成后停止光标闪烁
isTyping = false
if (showCursor) {
cursorJob?.cancel()
setText(fullText) // 确保最终文本不包含光标
}
}
// 开始光标闪烁效果
if (showCursor) {
cursorJob = getLifecycleScope().launch {
while (isActive && isTyping) {
cursorVisible = !cursorVisible
invalidate() // 请求重绘
delay(cursorBlinkSpeed)
}
}
}
}
/**
* 暂停打字效果
*/
fun pauseAnimation() {
typingJob?.cancel()
cursorJob?.cancel()
isTyping = false
}
/**
* 继续打字效果
*/
fun resumeAnimation() {
if (currentText.length < fullText.length) {
setTextWithAnimation(fullText)
}
}
/**
* 重置打字效果
*/
fun resetAnimation() {
cancelJobs()
currentText = ""
fullText = ""
cursorPosition = 0
text = ""
}
/**
* 设置打字速度
*/
fun setTypingSpeed(speed: Long) {
typingSpeed = speed
}
/**
* 设置光标闪烁速度
*/
fun setCursorBlinkSpeed(speed: Long) {
cursorBlinkSpeed = speed
}
/**
* 是否显示光标
*/
fun setShowCursor(show: Boolean) {
showCursor = show
if (!show) {
cursorJob?.cancel()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制光标
if (showCursor && cursorVisible && isTyping) {
val textWidth = textPaint.measureText(currentText)
val startX = paddingLeft + textWidth
val baseline = baseline.toFloat()
canvas.drawLine(startX, baseline - textSize, startX, baseline + 10, cursorPaint)
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cancelJobs()
}
private fun cancelJobs() {
typingJob?.cancel()
cursorJob?.cancel()
isTyping = false
}
private fun getLifecycleScope(): CoroutineScope {
return try {
// 尝试获取LifecycleOwner的scope
(context as? LifecycleOwner)?.lifecycleScope ?: CoroutineScope(Dispatchers.Main)
} catch (e: Exception) {
CoroutineScope(Dispatchers.Main)
}
}
override fun onSaveInstanceState(): Parcelable? {
val superState = super.onSaveInstanceState()
val savedState = SavedState(superState)
savedState.currentText = currentText
savedState.fullText = fullText
savedState.cursorPosition = cursorPosition
savedState.isTyping = isTyping
return savedState
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is SavedState) {
super.onRestoreInstanceState(state.superState)
currentText = state.currentText
fullText = state.fullText
cursorPosition = state.cursorPosition
isTyping = state.isTyping
text = currentText
// 如果之前正在打字,恢复动画
if (isTyping && currentText.length < fullText.length) {
setTextWithAnimation(fullText)
}
} else {
super.onRestoreInstanceState(state)
}
}
private class SavedState : BaseSavedState {
var currentText: String = ""
var fullText: String = ""
var cursorPosition: Int = 0
var isTyping: Boolean = false
constructor(superState: Parcelable?) : super(superState)
private constructor(parcel: Parcel) : super(parcel) {
currentText = parcel.readString() ?: ""
fullText = parcel.readString() ?: ""
cursorPosition = parcel.readInt()
isTyping = parcel.readInt() == 1
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeString(currentText)
out.writeString(fullText)
out.writeInt(cursorPosition)
out.writeInt(if (isTyping) 1 else 0)
}
companion object {
@JvmField
val CREATOR = object : Parcelable.Creator<SavedState> {
override fun createFromParcel(source: Parcel) = SavedState(source)
override fun newArray(size: Int) = arrayOfNulls<SavedState?>(size)
}
}
}
}
8.布局中使用:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.example.typewritertextviewdemo.TypewriterTextView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="20dp"
android:padding="16dp"
android:background="#FFF5F5"
android:textSize="20sp"
android:textColor="#333"
app:typingSpeed="60"
app:cursorBlinkSpeed="500"
app:showCursor="true"
app:typewriterText="Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!Hello, this is a typewriter effect using Kotlin Coroutines and Flow!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
9.实现效果:
10.总结:
10.1、优点
优势维度 | 具体描述 |
---|---|
动画流畅度与可控性 | 1. 借助协程 delay 机制,精准控制字符输出间隔(typingSpeed ),避免传统 Handler 内存泄漏风险; 2. 通过 Flow 流式发射文本片段,确保 UI 字符追加更新连贯无卡顿; 3. 支持「暂停 / 继续 / 重置」交互,光标闪烁速度、显示状态可动态配置,适配不同场景需求。 |
复用性与配置便捷性 | 1. 继承 AppCompatTextView ,天然兼容 TextView 原有属性(文字颜色、字号、字体等),无需额外适配; 2. 支持 XML 自定义属性(typewriterText /typingSpeed /showCursor 等),布局中可直接配置,减少代码重复; 3. 封装为独立自定义 View,可在 Activity/Fragment 中直接引用,降低业务代码与动效逻辑的耦合。 |
生命周期安全性 | 1. 优先绑定宿主 LifecycleOwner 的 lifecycleScope ,协程会随页面生命周期(如 onDestroy )自动取消; 2. 页面销毁(onDetachedFromWindow )时主动取消 typingJob /cursorJob ,兜底避免内存泄漏; 3. 实现 onSaveInstanceState /onRestoreInstanceState ,屏幕旋转或内存回收后可恢复打字进度(currentText /isTyping 等状态),提升用户体验。 |
视觉细节适配 | 1. 光标位置通过 textPaint.measureText(currentText) 实时计算,与文本长度精准联动,无偏移; 2. 光标仅在「打字中」且「开启显示」时闪烁,打字完成后自动停止,符合真实打字机的视觉逻辑; 3. 文本与光标使用独立 Paint 绘制,避免样式冲突,视觉效果统一。 |
10.2、缺点
不足维度 | 具体描述 |
---|---|
技术栈学习成本 | 1. 依赖 Kotlin 协程与 Flow 技术,对不熟悉该技术栈的开发团队存在额外学习成本; 2. 协程任务的状态(如 typingJob 是否活跃)调试难度高于传统 Handler 或 ValueAnimator 方案。 |
「继续」功能效率 | resumeAnimation() 需重新调用 setTextWithAnimation(fullText) ,本质是从当前进度重新遍历完整文本(而非断点续播);若 fullText 较长(如数百字符),会重复执行已完成的字符发射逻辑,产生冗余计算,影响效率。 |
性能损耗风险 | 光标闪烁通过 invalidate() 触发 onDraw 实现,每 cursorBlinkSpeed (默认 500ms)重绘一次;低性能设备或页面存在复杂 View(如列表、多动画)时,频繁重绘可能导致页面轻微卡顿。 |
功能灵活性局限 | 1. 仅支持纯文本逐字符追加,无法处理富文本(加粗、换行、图片)或自定义打字逻辑(如特定字符延迟、渐入效果); 2. 光标样式固定为垂直线,无接口支持自定义(如方块、下划线),无法满足个性化 UI 需求; 3. 未提供文本分段、换行特殊处理,长文本中换行符可能导致光标位置异常。 |