You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中实现带自动打字机效果的HTML转Widget方案咨询

在Flutter中实现带自动打字机效果的HTML渲染

核心思路

要同时实现HTML渲染和打字机效果,关键是将HTML解析为可逐段/逐字控制的富文本结构,再通过状态更新逐步渲染内容,而非直接依赖无法协同的现成插件。具体分为三步:

  1. 解码并解析原始HTML,提取文本和样式信息
  2. 实现打字机逻辑:通过定时器逐步更新显示的内容长度
  3. 自定义Widget渲染部分富文本,保留原HTML的样式(如加粗、列表结构)

实现步骤

1. 处理原始HTML内容

先把输入中带转义的HTML实体(如"、<)解码为真实的HTML字符串:

// 原始转义后的HTML
String escapedHtml = """
"<ol><li><strong>Personalized Gift</strong>: Consider giving her a thoughtful and personalized gift that shows how much you know and appreciate her. It could be a piece of jewelry, a custom-made item, or something related to her hobbies or interests.  </li><li><strong>Surprise Party</strong>: Organize a surprise party with close friends and family. Make sure to incorporate her favorite colors, themes, and activities into the party. You could also arrange for a live band, DJ, or hire a professional photographer to capture all the special moments. </li><li><strong> Romantic Dinner</strong>: Treat her to a romantic dinner at her favorite restaurant or plan a cozy dinner at home. Cook her favorite meal yourself or order from her preferred restaurant. Set the mood with candles, soft music, and maybe even a bottle of champagne or wine.</li></ol>"
""";

// 解码HTML实体,得到可解析的HTML字符串
String decodedHtml = HtmlUnescape().convert(escapedHtml).replaceAll('"', '');

2. 实现打字机效果的富文本Widget

自定义TypewriterWidget,接收带样式的TextSpan,通过定时器逐步渲染内容:

import 'dart:async';
import 'package:flutter/material.dart';

class TypewriterWidget extends StatefulWidget {
  final TextSpan textSpan;
  final Duration typingSpeed;

  const TypewriterWidget({
    super.key,
    required this.textSpan,
    this.typingSpeed = const Duration(milliseconds: 50),
  });

  @override
  State<TypewriterWidget> createState() => _TypewriterWidgetState();
}

class _TypewriterWidgetState extends State<TypewriterWidget> {
  int _currentDisplayLength = 0;
  late Timer _typingTimer;

  @override
  void initState() {
    super.initState();
    _startTyping();
  }

  // 启动打字定时器
  void _startTyping() {
    int totalChars = _calculateTotalCharacters(widget.textSpan);
    _typingTimer = Timer.periodic(widget.typingSpeed, (timer) {
      if (_currentDisplayLength >= totalChars) {
        timer.cancel();
        return;
      }
      setState(() => _currentDisplayLength++);
    });
  }

  // 递归计算所有文本的总字符数
  int _calculateTotalCharacters(TextSpan span) {
    int count = span.text?.length ?? 0;
    for (var child in span.children ?? []) {
      count += _calculateTotalCharacters(child);
    }
    return count;
  }

  // 根据当前长度构建部分TextSpan
  TextSpan _buildPartialTextSpan(TextSpan original, int remainingChars) {
    if (remainingChars <= 0) return const TextSpan(text: '');

    final currentText = original.text;
    if (currentText != null) {
      if (currentText.length <= remainingChars) {
        return TextSpan(
          text: currentText,
          style: original.style,
          children: original.children?.map((child) => _buildPartialTextSpan(child, remainingChars - currentText.length)).toList(),
        );
      } else {
        return TextSpan(
          text: currentText.substring(0, remainingChars),
          style: original.style,
        );
      }
    } else {
      List<TextSpan> newChildren = [];
      int remaining = remainingChars;
      for (var child in original.children ?? []) {
        if (remaining <= 0) break;
        final childCharCount = _calculateTotalCharacters(child);
        if (childCharCount <= remaining) {
          newChildren.add(child);
          remaining -= childCharCount;
        } else {
          newChildren.add(_buildPartialTextSpan(child, remaining));
          remaining = 0;
        }
      }
      return TextSpan(style: original.style, children: newChildren);
    }
  }

  @override
  void dispose() {
    _typingTimer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return RichText(
      text: _buildPartialTextSpan(widget.textSpan, _currentDisplayLength),
      softWrap: true,
    );
  }
}

3. 解析HTML并结合打字机Widget

使用flutter_html库将HTML转换为TextSpan,为每个列表项绑定打字机效果:

import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_html/html_parser.dart';
import 'package:html/dom.dart' as dom;

// 构建主Widget
Widget buildHtmlTypewriter() {
  return Html(
    data: decodedHtml,
    customRender: {
      'li': (context, _, __, ___) {
        final dom.Element? liElement = context.tree.element;
        if (liElement == null) return const SizedBox();

        // 将li内的HTML转换为带样式的TextSpan
        final TextSpan contentSpan = HtmlParser(
          htmlData: liElement.innerHtml,
          defaultTextStyle: DefaultTextStyle.of(context).style,
        ).parse();

        // 获取有序列表的序号
        final int index = liElement.parent?.children.indexOf(liElement) ?? 0;

        return Padding(
          padding: const EdgeInsets.symmetric(vertical: 8.0),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('${index + 1}. ', style: const TextStyle(fontWeight: FontWeight.bold)),
              Expanded(child: TypewriterWidget(textSpan: contentSpan)),
            ],
          ),
        );
      },
    },
  );
}

关键注意事项

  • HTML实体解码:必须先处理转义字符,否则无法正确解析HTML结构
  • 样式保留:通过TextSpan传递样式信息,确保加粗、字体等样式在打字过程中正常显示
  • 内存管理:在TypewriterWidget销毁时取消定时器,避免内存泄漏
  • 列表序号处理:通过解析DOM节点的位置生成有序列表的序号,保持原HTML的结构

内容的提问来源于stack exchange,提问作者Jinto Joseph

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 00:56:05