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

Flutter应用中HTML文档的搜索、高亮及跳转功能实现方案咨询

实现Flutter HTML内容的搜索高亮与跳转功能

针对你用flutter_html: ^2.2.1渲染HTML的场景,有两种可行方案实现类似Ctrl+F的搜索功能:

方案一:扩展flutter_html原生实现

利用flutter_html的文本转换能力,自定义处理逻辑来高亮匹配内容,并结合滚动控制器实现跳转:

  1. 自定义文本转换处理器
    创建一个SearchTransformer,遍历HTML的文本节点,将匹配关键词的部分替换为带高亮样式的文本片段:
class SearchTransformer extends HtmlTransformer {
  final String searchKeyword;
  final TextStyle highlightStyle;

  SearchTransformer({required this.searchKeyword, required this.highlightStyle});

  @override
  List<Widget> transform({
    required Node node,
    required BuildContext context,
    required Widget child,
  }) {
    if (node is TextNode && searchKeyword.isNotEmpty) {
      final text = node.text;
      final matches = RegExp(searchKeyword, caseSensitive: false).allMatches(text);
      if (matches.isEmpty) return [child];

      final spans = <TextSpan>[];
      int lastEnd = 0;

      for (final match in matches) {
        if (match.start > lastEnd) {
          spans.add(TextSpan(text: text.substring(lastEnd, match.start)));
        }
        spans.add(TextSpan(
          text: text.substring(match.start, match.end),
          style: highlightStyle,
        ));
        lastEnd = match.end;
      }

      if (lastEnd < text.length) {
        spans.add(TextSpan(text: text.substring(lastEnd)));
      }

      return [RichText(text: TextSpan(children: spans))];
    }
    return [child];
  }
}
  1. 集成到Html组件
    在Html组件中传入自定义的transformer,并配合ScrollController实现滚动跳转:
class HtmlSearchPage extends StatefulWidget {
  final String htmlContent;
  const HtmlSearchPage({required this.htmlContent, Key? key}) : super(key: key);

  @override
  State<HtmlSearchPage> createState() => _HtmlSearchPageState();
}

class _HtmlSearchPageState extends State<HtmlSearchPage> {
  final _scrollController = ScrollController();
  String _searchKeyword = '';
  final _highlightStyle = const TextStyle(
    backgroundColor: Colors.yellow,
    fontWeight: FontWeight.bold,
  );
  int _currentMatchIndex = 0;
  List<GlobalKey> _matchKeys = [];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: TextField(
          onChanged: (value) {
            setState(() {
              _searchKeyword = value;
              _currentMatchIndex = 0;
              _matchKeys.clear();
            });
          },
          decoration: const InputDecoration(hintText: '输入搜索关键词'),
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.arrow_upward),
            onPressed: _jumpToPrevMatch,
          ),
          IconButton(
            icon: const Icon(Icons.arrow_downward),
            onPressed: _jumpToNextMatch,
          ),
        ],
      ),
      body: SingleChildScrollView(
        controller: _scrollController,
        child: Html(
          data: widget.htmlContent,
          transformers: [
            SearchTransformer(
              searchKeyword: _searchKeyword,
              highlightStyle: _highlightStyle,
            ),
          ],
          customRender: {
            'p': (context, child, attributes, _) {
              if (_searchKeyword.isNotEmpty && child != null) {
                final key = GlobalKey();
                _matchKeys.add(key);
                return KeyedSubtree(key: key, child: child);
              }
              return child;
            },
          },
        ),
      ),
    );
  }

  void _jumpToNextMatch() {
    if (_matchKeys.isEmpty) return;
    _currentMatchIndex = (_currentMatchIndex + 1) % _matchKeys.length;
    _scrollToMatch();
  }

  void _jumpToPrevMatch() {
    if (_matchKeys.isEmpty) return;
    _currentMatchIndex = (_currentMatchIndex - 1 + _matchKeys.length) % _matchKeys.length;
    _scrollToMatch();
  }

  void _scrollToMatch() {
    final context = _matchKeys[_currentMatchIndex].currentContext;
    if (context != null) {
      Scrollable.ensureVisible(
        context,
        duration: const Duration(milliseconds: 300),
        alignment: 0.5,
      );
    }
  }
}

方案二:改用WebView实现(更便捷的原生搜索体验)

如果不需要深度定制HTML渲染样式,可以用webview_flutter包加载HTML,利用浏览器原生的搜索能力:

  1. 加载HTML并注入搜索JS
class WebViewSearchPage extends StatefulWidget {
  final String htmlContent;
  const WebViewSearchPage({required this.htmlContent, Key? key}) : super(key: key);

  @override
  State<WebViewSearchPage> createState() => _WebViewSearchPageState();
}

class _WebViewSearchPageState extends State<WebViewSearchPage> {
  late WebViewController _webViewController;
  String _searchKeyword = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: TextField(
          onChanged: (value) {
            _searchKeyword = value;
            _triggerSearch();
          },
          decoration: const InputDecoration(hintText: '输入搜索关键词'),
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.arrow_upward),
            onPressed: () => _webViewController.runJavascript('findPrevious()'),
          ),
          IconButton(
            icon: const Icon(Icons.arrow_downward),
            onPressed: () => _webViewController.runJavascript('findNext()'),
          ),
        ],
      ),
      body: WebView(
        initialUrl: Uri.dataFromString(
          widget.htmlContent,
          mimeType: 'text/html',
          encoding: Encoding.getByName('utf-8'),
        ).toString(),
        onWebViewCreated: (controller) {
          _webViewController = controller;
          // 注入搜索JS函数
          _webViewController.runJavascript('''
            let searchRange = document.body.createTextRange();
            let currentPos = 0;
            function findKeyword(keyword) {
              if(!keyword) return;
              searchRange.moveStart('character', currentPos);
              let found = searchRange.findText(keyword);
              if(found) {
                searchRange.select();
                currentPos = searchRange.endOffset;
              } else {
                currentPos = 0;
                searchRange.moveStart('character', 0);
                found = searchRange.findText(keyword);
                if(found) {
                  searchRange.select();
                  currentPos = searchRange.endOffset;
                }
              }
            }
            function findNext() {
              findKeyword('$_searchKeyword');
            }
            function findPrevious() {
              searchRange.moveStart('character', currentPos - 2 * $_searchKeyword.length);
              let found = searchRange.findText($_searchKeyword, 0, 1);
              if(found) {
                searchRange.select();
                currentPos = searchRange.startOffset;
              } else {
                currentPos = document.body.innerText.length;
                searchRange.moveStart('character', currentPos);
                found = searchRange.findText($_searchKeyword, 0, 1);
                if(found) {
                  searchRange.select();
                  currentPos = searchRange.startOffset;
                }
              }
            }
          ''');
        },
      ),
    );
  }

  void _triggerSearch() {
    _webViewController.runJavascript('findKeyword("$_searchKeyword")');
  }
}

注意事项

  • 方案一中的文本匹配逻辑需要处理大小写、正则转义等细节,避免特殊字符导致匹配异常
  • 方案二的WebView方式依赖浏览器内核,不同平台的搜索体验可能略有差异
  • 如果flutter_html的版本更新,需要调整transformer或customRender的适配代码

内容的提问来源于stack exchange,提问作者blue dream

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.23 01:08:25