Android使用Kotlin协程+Flow实现打字机效果
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.typewritertextviewdemoimport 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? = nullprivate var cursorJob: Job? = null// 光标绘制相关private val cursorPaint = Paint().apply {color = currentTextColorstyle = Paint.Style.FILLstrokeWidth = 4f}// 文本绘制相关private val textPaint = TextPaint().apply {color = currentTextColortextSize = textSizetypeface = 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 = textcurrentText = ""cursorPosition = 0isTyping = true// 开始打字效果typingJob = getLifecycleScope().launch {flow {fullText.forEachIndexed { index, char ->delay(typingSpeed)currentText = fullText.substring(0, index + 1)cursorPosition = currentText.lengthemit(currentText)}}.collect {setText(it)// 请求重绘以更新光标invalidate()}// 打字完成后停止光标闪烁isTyping = falseif (showCursor) {cursorJob?.cancel()setText(fullText) // 确保最终文本不包含光标}}// 开始光标闪烁效果if (showCursor) {cursorJob = getLifecycleScope().launch {while (isActive && isTyping) {cursorVisible = !cursorVisibleinvalidate() // 请求重绘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 = 0text = ""}/*** 设置打字速度*/fun setTypingSpeed(speed: Long) {typingSpeed = speed}/*** 设置光标闪烁速度*/fun setCursorBlinkSpeed(speed: Long) {cursorBlinkSpeed = speed}/*** 是否显示光标*/fun setShowCursor(show: Boolean) {showCursor = showif (!show) {cursorJob?.cancel()}invalidate()}override fun onDraw(canvas: Canvas) {super.onDraw(canvas)// 绘制光标if (showCursor && cursorVisible && isTyping) {val textWidth = textPaint.measureText(currentText)val startX = paddingLeft + textWidthval 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 = currentTextsavedState.fullText = fullTextsavedState.cursorPosition = cursorPositionsavedState.isTyping = isTypingreturn savedState}override fun onRestoreInstanceState(state: Parcelable?) {if (state is SavedState) {super.onRestoreInstanceState(state.superState)currentText = state.currentTextfullText = state.fullTextcursorPosition = state.cursorPositionisTyping = state.isTypingtext = 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 = 0var isTyping: Boolean = falseconstructor(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 {@JvmFieldval 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.TypewriterTextViewandroid: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. 未提供文本分段、换行特殊处理,长文本中换行符可能导致光标位置异常。 |
11.项目Demo源码:
https://gitee.com/jackning_admin/typewritertextviewdemo