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

发布于:2025-08-19 ⋅ 阅读:(15) ⋅ 点赞:(0)

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.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. 优先绑定宿主 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


网站公告

今日签到

点亮在社区的每一天
去签到