当前位置: 首页 > news >正文

Android使用Kotlin协程+Flow实现打字机效果

Android使用Kotlin协程+Flow实现打字机效果

1.前言:

最近在开发Ai智能问答对话项目时,需要实现一个打字机效果,于是使用Kotlin协程+Flow实现了这个效果,感觉还挺不错的,直接上代码.

2.简介:

Android 打字机效果是一种模拟传统打字机逐字符输出文本的 UI 动效,通过文字逐个显示 + 光标动态闪烁的组合,营造出文本 “实时输入” 的视觉体验,常用于 App 欢迎页、剧情对话、数据加载提示等场景,能提升用户注意力与交互趣味性。

3. 核心实现逻辑

其本质是通过 “文本分段更新”“光标周期性重绘” 实现,主流技术方案基于 Kotlin 协程+FlowHandler 完成时序控制,核心步骤如下:

  1. 文本拆分:将完整目标文本按字符 / 词组拆分,规划逐段显示的顺序。
  2. 时序控制:通过协程 delay()Handler.postDelayed() 控制每段文本的显示间隔(即 “打字速度”)。
  3. 文本更新:每隔指定时间,更新 TextView 显示的文本(从 “空” 逐步拼接至完整文本)。
  4. 光标绘制:在文本末尾绘制竖线 / 方块光标,通过周期性切换 “显示 / 隐藏” 状态(即 “光标闪烁速度”)模拟输入光标效果。
  5. 状态管理:处理 “暂停 / 继续 / 重置” 等交互,以及页面销毁、配置变更(如屏幕旋转)时的状态保存与恢复。

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. 优先绑定宿主 LifecycleOwnerlifecycleScope,协程会随页面生命周期(如 onDestroy)自动取消; 2. 页面销毁(onDetachedFromWindow)时主动取消 typingJob/cursorJob,兜底避免内存泄漏; 3. 实现 onSaveInstanceState/onRestoreInstanceState,屏幕旋转或内存回收后可恢复打字进度(currentText/isTyping 等状态),提升用户体验。
视觉细节适配1. 光标位置通过 textPaint.measureText(currentText) 实时计算,与文本长度精准联动,无偏移; 2. 光标仅在「打字中」且「开启显示」时闪烁,打字完成后自动停止,符合真实打字机的视觉逻辑; 3. 文本与光标使用独立 Paint 绘制,避免样式冲突,视觉效果统一。

10.2、缺点

不足维度具体描述
技术栈学习成本1. 依赖 Kotlin 协程与 Flow 技术,对不熟悉该技术栈的开发团队存在额外学习成本; 2. 协程任务的状态(如 typingJob 是否活跃)调试难度高于传统 HandlerValueAnimator 方案。
「继续」功能效率resumeAnimation() 需重新调用 setTextWithAnimation(fullText),本质是从当前进度重新遍历完整文本(而非断点续播);若 fullText 较长(如数百字符),会重复执行已完成的字符发射逻辑,产生冗余计算,影响效率。
性能损耗风险光标闪烁通过 invalidate() 触发 onDraw 实现,每 cursorBlinkSpeed(默认 500ms)重绘一次;低性能设备或页面存在复杂 View(如列表、多动画)时,频繁重绘可能导致页面轻微卡顿。
功能灵活性局限1. 仅支持纯文本逐字符追加,无法处理富文本(加粗、换行、图片)或自定义打字逻辑(如特定字符延迟、渐入效果); 2. 光标样式固定为垂直线,无接口支持自定义(如方块、下划线),无法满足个性化 UI 需求; 3. 未提供文本分段、换行特殊处理,长文本中换行符可能导致光标位置异常。

11.项目Demo源码:

https://gitee.com/jackning_admin/typewritertextviewdemo

http://www.xdnf.cn/news/1324711.html

相关文章:

  • Python 作用域 (scope) 与闭包 (closure)
  • 【学习嵌入式-day-27-进程间通信】
  • Docker常见指令速查
  • 用户认证技术
  • STL库——string(类函数学习)
  • SQL详细语法教程(六)存储+索引
  • AI心理助手开发文档
  • 在python中等号左边的都是对象,在matlab中等号a = 3+2 a就是个变量
  • 力扣hot100:盛最多水的容器:双指针法高效求解最大容量问题(11)
  • openfeign 只有接口如何创建bean的
  • Linux设备树简介
  • vue3入门-v-model、ref和reactive讲解
  • Leetcode 16 java
  • Effective C++ 条款49:了解new-handler的行为
  • 力扣 hot100 Day77
  • 单片机驱动LCD显示模块LM6029BCW
  • 机器翻译论文阅读方法:顶会(ACL、EMNLP)论文解析技巧
  • STM32学习笔记14-I2C硬件控制
  • 大数据计算引擎(四)—— Impala
  • Fluss:颠覆Kafka的面向分析的实时流存储
  • GPT-5之后:当大模型更新不再是唯一焦点
  • 深度学习必然用到的概率知识
  • Vue 3中watch的返回值:解锁监听的隐藏技巧
  • 敏感数据加密平台设计实战:如何为你的系统打造安全“保险柜”
  • 遥感机器学习入门实战教程 | Sklearn 案例②:PCA + k-NN 分类与评估
  • Day8--滑动窗口与双指针--1004. 最大连续1的个数 III,1658. 将 x 减到 0 的最小操作数,3641. 最长半重复子数组
  • 具身智能2硬件架构(人形机器人)摘自Openloong社区
  • Next.js 中的 SEO:搜索引擎优化最佳实践
  • flutter项目适配鸿蒙
  • JMeter与大模型融合应用之构建AI智能体:评审性能测试脚本