Flutter富文本实现学习

Flutter 代码如何实现一个带有富文本显示和交互的页面。

前置知识点学习

RealRichText

`RealRichText` 和 `ImageSpan` 不是 Flutter 框架中内置的组件,而是自定义的组件或来自第三方库。这些组件的实现可以提供比标准 `RichText` 更丰富的功能,比如在富文本中插入图片、处理点击事件等。由于这些组件在标准 Flutter 中不存在,我将解释如何可能实现类似的功能。

TextSpan

`TextSpan` 是 Flutter 中用于构建富文本的基本组件之一。它允许你在同一个文本组件中混合和匹配不同的文本样式和手势识别功能。`TextSpan` 通常与 `RichText` 组件一起使用,以显示复杂的文本布局。

`TextSpan` 的基本结构

`TextSpan` 是一个不可变的、递归的数据结构,可以包含其他 `TextSpan`,从而允许嵌套不同的文本样式。

Dart 复制代码
TextSpan({
  TextStyle? style,
  String? text,
  List<InlineSpan>? children,
  GestureRecognizer? recognizer,
  String? semanticsLabel,
})

关键属性

`style`:

  • 指定文本的样式,例如字体大小、颜色、粗细等。
  • 类型为 `TextStyle`。

`text`:

  • 要显示的文本字符串。
  • `text` 和 `children` 之间是互斥的,如果需要在一个 `TextSpan` 中显示多个文本片段,通常使用 `children`。

`children`:

  • 一个 `InlineSpan`(通常为 `TextSpan`)的列表,用于嵌套子文本。
  • 允许创建复杂的文本结构,如不同样式的文本段落。

`recognizer`:

  • 用于处理文本的手势识别,例如点击事件。
  • 通常使用 `TapGestureRecognizer` 来处理点击事件。

`semanticsLabel`:

  • 为文本提供一个可供屏幕阅读器使用的标签,帮助无障碍访问。

使用示例

下面是一个简单的例子,展示如何使用 `TextSpan` 来创建一个包含多种样式和交互的文本。

Dart 复制代码
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

class RichTextExample extends StatelessWidget {
  const RichTextExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Rich Text Example")),
      body: Center(
        child: RichText(
          text: TextSpan(
            text: 'Hello ',
            style: const TextStyle(color: Colors.black, fontSize: 18),
            children: <TextSpan>[
              const TextSpan(
                  text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)),
              const TextSpan(
                text: ' and ',
              ),
              const TextSpan(
                text: 'italic',
                style: TextStyle(fontStyle: FontStyle.italic),
              ),
              TextSpan(
                text: ' clickable ',
                style: const TextStyle(color: Colors.blue),
                recognizer: TapGestureRecognizer()
                  ..onTap = () {
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('Clickable text clicked!')),
                    );
                  },
              ),
              const TextSpan(
                text: 'text.',
              ),
            ],
          ),
        ),
      ),
    );
  }
}

ImageSpan

`ImageSpan` 是一种在富文本中嵌入图片的技术。在 Flutter 中,虽然没有直接提供一个名为 `ImageSpan` 的组件,但你可以通过使用 `WidgetSpan` 来实现类似的功能。`WidgetSpan` 允许你在 `RichText` 中插入任意的 Flutter 小部件,包括图片。

使用 `WidgetSpan` 实现 `ImageSpan` 的功能

`WidgetSpan` 是 `InlineSpan` 的子类,可以在 `TextSpan` 列表中使用来嵌入小部件。通过这种方式,你可以在文本段落中插入图片或者其他小部件。

示例代码

Dart 复制代码
import 'package:flutter/material.dart';

class RichTextWithImageExample extends StatelessWidget {
  const RichTextWithImageExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Rich Text with Image Example")),
      body: Center(
        child: RichText(
          text: TextSpan(
              style: const TextStyle(color: Colors.black, fontSize: 18),
              children: <InlineSpan>[
                const TextSpan(text: 'This is an example of '),
                WidgetSpan(
                    child: Image.asset(
                  "static/demo.png",
                  width: 24,
                  height: 24,
                )),
                const TextSpan(text: ' in a rich text widget.'),
              ]),
        ),
      ),
    );
  }
}

解释

`RichText` 和 `TextSpan`:

  • `RichText` 用于显示复杂的文本布局,`TextSpan` 用于定义文本的样式和内容。

`WidgetSpan`:

  • `WidgetSpan` 可以嵌入任何小部件。在这个例子中,它用于插入一个图片。
  • `child` 属性接受一个 `Widget`,这里使用 `Image.asset` 来加载本地图片。

图片资源:

  • 确保图片路径正确,并在 `pubspec.yaml` 中声明图片资源。
  • 例如:
Dart 复制代码
flutter:
  assets:
    - assets/demo.png

使用场景

  • 图文混排: 当需要在同一个文本段落中展示图片和文本,比如图标、表情符号或其他装饰性元素。
  • 动态内容: 在文章或聊天应用中以富文本形式展示内容,图片作为内嵌元素。
  • 自定义格式: 创建带有图片的复杂格式文本,比如新闻应用中的插图或注释。

通过 `WidgetSpan`,你可以在 Flutter 的文本组件中灵活地插入图片和其他小部件,实现更复杂的文本布局和丰富的用户界面体验。

TapGestureRecognizer

`TapGestureRecognizer` 是 Flutter 中用于检测点击手势的一个手势识别器。它通常用于处理文本中的点击事件,特别是在 `TextSpan` 中,使得文本中的某些部分可以响应用户的点击操作。

基本使用

`TapGestureRecognizer` 是 `GestureRecognizer` 的一个子类,它专门用于处理点击(tap)事件。通常情况下,它与 `RichText` 和 `TextSpan` 结合使用,以实现文本的可点击功能。

如何使用 `TapGestureRecognizer`

以下是一个简单示例,展示如何在 `TextSpan` 中使用 `TapGestureRecognizer`,使得文本的一部分可以被点击并响应事件:

Dart 复制代码
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class TapGestureExample extends StatelessWidget {
  const TapGestureExample({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Tap Gesture Recognizer Example")),
      body: Center(
        child: RichText(
          text: TextSpan(
            text: 'Click ',
            style: const TextStyle(color: Colors.black, fontSize: 18),
            children: <TextSpan>[
              TextSpan(
                text: 'here',
                style: const TextStyle(color: Colors.blue, decoration: TextDecoration.underline),
                recognizer: TapGestureRecognizer()
                  ..onTap = () {
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('Text clicked!')),
                    );
                  },
              ),
              const TextSpan(
                text: ' to see the effect.',
              ),
            ],
          ),
        ),
      ),
    );
  }
}

解释

`RichText` 和 `TextSpan`:

  • `RichText` 用于显示富文本。通过 `TextSpan`,你可以定义不同的文本样式和内容。

`TapGestureRecognizer`:

  • 创建一个 `TapGestureRecognizer` 实例并将其分配给 `TextSpan` 的 `recognizer` 属性。
  • 使用 `..onTap`(级联操作符)来定义 `onTap` 回调函数,当用户点击该文本时调用。

响应点击:

  • 在 `onTap` 回调中使用 `ScaffoldMessenger` 显示一个 `SnackBar`,以反馈用户的点击操作。

注意事项

释放资源:

  • 如果在有状态的小部件中使用 `TapGestureRecognizer`,请确保在 `dispose` 方法中调用 `dispose` 方法来释放手势识别器,以避免内存泄漏。

多手势识别:

  • `TapGestureRecognizer` 可以与其他类型的手势识别器一起使用。使用 `GestureDetector` 可以创建更复杂的手势识别场景。

用户体验:

  • 确保可点击的文本有视觉上的提示,如颜色变化或下划线,以便用户能够清楚地识别哪些文本是可交互的。

通过 `TapGestureRecognizer`,你可以为文本添加交互功能,使得应用的用户界面更加动态和响应用户操作。

富文本代码实现学习

Dart 复制代码
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:gsy_flutter_demo/widget/rich/real_rich_text.dart';

class RichTextDemoPage extends StatefulWidget {
  const RichTextDemoPage({super.key});

  @override
  _RichTextDemoState createState() => _RichTextDemoState();
}

class _RichTextDemoState extends State<RichTextDemoPage> {
  @override
  Widget build(BuildContext mainContext) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("RichTextDemoPage"),
      ),
      body: Container(
        margin: const EdgeInsets.all(10),
        child: Builder(builder: (context) {
          return Center(
            child: RealRichText([
              TextSpan(
                text: "A Text Link",
                style: const TextStyle(color: Colors.red, fontSize: 14),
                recognizer: TapGestureRecognizer()
                  ..onTap = () {
                    show(context, "Link Clicked.");
                  },
              ),
              ImageSpan(
                const AssetImage("static/demo.png"),
                imageWidth: 24,
                imageHeight: 24,
              ),
              ImageSpan(const AssetImage("static/demo.png"),
                  imageWidth: 24,
                  imageHeight: 24,
                  margin: const EdgeInsets.symmetric(horizontal: 10)),
              const TextSpan(
                text: "哈哈哈",
                style: TextStyle(color: Colors.yellow, fontSize: 14),
              ),
              TextSpan(
                text: "@Somebody",
                style: const TextStyle(
                    color: Colors.black,
                    fontSize: 14,
                    fontWeight: FontWeight.bold),
                recognizer: TapGestureRecognizer()
                  ..onTap = () {
                    show(context, "Link Clicked.");
                  },
              ),
              TextSpan(
                text: " #RealRichText# ",
                style: const TextStyle(color: Colors.blue, fontSize: 14),
                recognizer: TapGestureRecognizer()
                  ..onTap = () {
                    show(context, "Link Clicked.");
                  },
              ),
              const TextSpan(
                text: "showing a bigger image",
                style: TextStyle(color: Colors.black, fontSize: 14),
              ),
              ImageSpan(const AssetImage("static/demo.png"),
                  imageWidth: 24,
                  imageHeight: 24,
                  margin: const EdgeInsets.symmetric(horizontal: 5)),
              const TextSpan(
                text: "and seems working perfect......",
                style: TextStyle(color: Colors.black, fontSize: 14),
              ),
            ]),
          );
        }),
      ),
    );
  }

  show(context, text) {
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
      content: Text(text),
      action: SnackBarAction(
        label: 'ACTION',
        onPressed: () {
          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
            content: Text('You pressed snackbar\'s action.'),
          ));
        },
      ),
    ));
  }
}

三方flutter库中的富文本实现源码

Dart 复制代码
import 'dart:ui' as ui show Image;

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

/// According to the related Flutter Issues(#2022) ,
/// Inline-Image-In-Text is a long-time(2 years) missing feature since RichText(or the underlying Paragraph) does only support pure text.
/// But we can solve this problem in a simple/tricky way:
///
/// 1. Regarde the images as a particular blank TextSpan,
///   convert image's width and height to textspan's letterSpacing and fontSize.
///   the origin paragraph will do the layout operation and leave the desired image space for us.
/// 2. Override the paint function,
///   calculate the right offset via the getOffsetForCaret() api to draw the image over the space.
///
/// The only thing you have to do is converting your origin text to a TextSpan/ImageSpan List first.
///
/// {@tool sample}
///
/// ```dart
/// RealRichText([
///            TextSpan(
///              text: "showing a bigger image",
///              style: TextStyle(color: Colors.black, fontSize: 14),
///            ),
///            ImageSpan(
///              AssetImage("packages/real_rich_text/images/emoji_10.png"),
///              width: 40,
///              height: 40,
///            ),
///            TextSpan(
///              text: "and seems working perfect......",
///              style: TextStyle(color: Colors.black, fontSize: 14),
///            ),
///          ])
/// ```
/// {@end-tool}
///
class RealRichText extends Text {
  final List<TextSpan> textSpanList;

  const RealRichText(
    this.textSpanList, {super.key,
    TextStyle? style,
    TextAlign textAlign = TextAlign.start,
    TextDirection? textDirection,
    bool softWrap = true,
    TextOverflow overflow = TextOverflow.clip,
    TextScaler? textScaler,
    int? maxLines,
    Locale? locale,
  }) : super("",
            style: style,
            textAlign: textAlign,
            textDirection: textDirection,
            softWrap: softWrap,
            overflow: overflow,
            textScaler: textScaler,
            maxLines: maxLines,
            locale: locale);

  List<TextSpan> extractAllNestedChildren(TextSpan textSpan) {
    if (textSpan.children == null || textSpan.children!.isEmpty) {
      return [textSpan];
    }
    List<TextSpan> childrenSpan = [];
    for (var child in textSpan.children!) {
      childrenSpan.addAll(extractAllNestedChildren(child as TextSpan));
    }
    return childrenSpan;
  }

  @override
  Widget build(BuildContext context) {
    final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
    TextStyle? effectiveTextStyle = style;
    if (style == null || style!.inherit) {
      effectiveTextStyle = defaultTextStyle.style.merge(style);
    }
    if (MediaQuery.boldTextOf(context)) {
      effectiveTextStyle =
          effectiveTextStyle!.merge(const TextStyle(fontWeight: FontWeight.bold));
    }

    TextSpan textSpan = TextSpan(
        style: effectiveTextStyle,
        text: "",
        children: extractAllNestedChildren(TextSpan(
          style: effectiveTextStyle,
          text: "",
          children: textSpanList,
        )));

    // pass the context to ImageSpan to create a ImageConfiguration
    for (var f in textSpan.children!) {
      if (f is ImageSpan) {
        f.updateImageConfiguration(context);
      }
    }

    Widget result = _RichTextWrapper(
        textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
        textDirection: textDirection,
        // RichText uses Directionality.of to obtain a default if this is null.
        locale: locale,
        // RichText uses Localizations.localeOf to obtain a default if this is null
        softWrap: softWrap ?? defaultTextStyle.softWrap,
        overflow: overflow ?? defaultTextStyle.overflow,
        textScaler: textScaler ?? MediaQuery.textScalerOf(context),
        maxLines: maxLines ?? defaultTextStyle.maxLines,
        text: textSpan);
    if (semanticsLabel != null) {
      result = Semantics(
          textDirection: textDirection,
          label: semanticsLabel,
          child: ExcludeSemantics(
            child: result,
          ));
    }
    return result;
  }
}

/// Since flutter engine does not support inline-image for now, we have to support this feature via a tricky solution:
/// convert image to a particular TextSpan whose text always be \u200B(a zero-width-space).
/// set letterSpacing by the required image width
/// set fontSize by the required image height
class ImageSpan extends TextSpan {
  final double imageWidth;
  final double imageHeight;
  final EdgeInsets? margin;
  final ImageProvider imageProvider;
  final ImageResolver imageResolver;

  ImageSpan(
    this.imageProvider, {
    this.imageWidth = 14.0,
    this.imageHeight = 14.0,
    this.margin,
    super.recognizer,
  })  : imageResolver = ImageResolver(imageProvider),
        super(
            style: TextStyle(
                color: Colors.transparent,
                letterSpacing:
                    imageWidth + (margin == null ? 0 : margin.horizontal),
                height: 1,
                fontSize: (imageHeight / 1.15) +
                    (margin == null ? 0 : margin.vertical)),
            text: "\u200B",
            children: []);

  void updateImageConfiguration(BuildContext context) {
    imageResolver.updateImageConfiguration(context, imageWidth, imageHeight);
  }

  double get width => imageWidth + (margin == null ? 0 : margin!.horizontal);

  double get height => imageHeight + (margin == null ? 0 : margin!.vertical);
}

typedef ImageResolverListener = void Function(
    ImageInfo imageInfo, bool synchronousCall);

class ImageResolver {
  final ImageProvider imageProvider;

  ImageStream? _imageStream;
  ImageConfiguration? _imageConfiguration;
  ui.Image? image;
  ImageResolverListener? _listener;

  ImageResolver(this.imageProvider);

  /// set the ImageConfiguration from outside
  void updateImageConfiguration(
      BuildContext context, double width, double height) {
    _imageConfiguration = createLocalImageConfiguration(
      context,
      size: Size(width, height),
    );
  }

  ImageStreamListener? imageStreamListener;

  void resolve(ImageResolverListener listener) {
    assert(_imageConfiguration != null);

    final ImageStream? oldImageStream = _imageStream;
    _imageStream = imageProvider.resolve(_imageConfiguration!);
    assert(_imageStream != null);

    _listener = listener;
    if (_imageStream!.key != oldImageStream?.key) {
      if (imageStreamListener != null) {
        oldImageStream?.removeListener(imageStreamListener!);
      }
      imageStreamListener ??= ImageStreamListener(_handleImageChanged);
      _imageStream!.addListener(imageStreamListener!);
    }
  }

  void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) {
    image = imageInfo.image;
    _listener?.call(imageInfo, synchronousCall);
  }

  void addListening() {
    if (_listener != null) {
      imageStreamListener ??= ImageStreamListener(_handleImageChanged);
      _imageStream?.addListener(imageStreamListener!);
    }
  }

  void stopListening() {
    if (imageStreamListener != null) {
      _imageStream?.removeListener(imageStreamListener!);
    }
  }
}

/// Just a subclass of RichText for overriding createRenderObject
/// to return a [_RealRichRenderParagraph] object
///
/// No more special purpose.
class _RichTextWrapper extends RichText {
  _RichTextWrapper({
    required TextSpan super.text,
    super.textAlign,
    super.textDirection,
    super.softWrap,
    super.overflow,
    super.textScaler,
    super.maxLines,
    super.locale,
  })  : assert(maxLines == null || maxLines > 0);

  @override
  RenderParagraph createRenderObject(BuildContext context) {
    assert(textDirection != null || debugCheckHasDirectionality(context));
    return _RealRichRenderParagraph(
      text as TextSpan,
      textAlign: textAlign,
      textDirection: textDirection ?? Directionality.of(context),
      softWrap: softWrap,
      overflow: overflow,
      textScaler: textScaler,
      maxLines: maxLines,
      locale: locale ?? Localizations.localeOf(context),
    );
  }
}

/// paint the image on the top of those ImageSpan's blank space
class _RealRichRenderParagraph extends RenderParagraph {
  _RealRichRenderParagraph(TextSpan super.text,
      {required super.textAlign,
      required super.textDirection,
      required super.softWrap,
      required super.overflow,
      required super.textScaler,
      super.maxLines,
      super.locale});

  @override
  void paint(PaintingContext context, Offset offset) {
    super.paint(context, offset);

    // Here it is!
    paintImageSpan(context, offset);
  }

  @override
  void attach(covariant Object owner) {
    super.attach(owner as PipelineOwner);
    for (var textSpan in (text as TextSpan).children!) {
      if (textSpan is ImageSpan) {
        textSpan.imageResolver.addListening();
      }
    }
  }

  @override
  void detach() {
    super.detach();
    for (var textSpan in (text as TextSpan).children!) {
      if (textSpan is ImageSpan) {
        textSpan.imageResolver.stopListening();
      }
    }
  }

  @override
  void performLayout() {
    super.performLayout();

    debugPrint("size = $size");
  }

  /// this method draws inline-image over blank text space.
  void paintImageSpan(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    final Rect bounds = offset & size;

    debugPrint("_RealRichRenderParagraph offset=$offset bounds=$bounds");

    canvas.save();

    int textOffset = 0;
    for (TextSpan textSpan
        in (text as TextSpan).children as Iterable<TextSpan>) {
      if (textSpan is ImageSpan) {
        // this is the top-center point of the ImageSpan
        Offset offsetForCaret = getOffsetForCaret(
          TextPosition(offset: textOffset),
          bounds,
        );

        // found this is a overflowed image. ignore it
        if (textOffset != 0 &&
            offsetForCaret.dx == 0 &&
            offsetForCaret.dy == 0) {
          return;
        }

        // this is the top-left point of the ImageSpan.
        // Usually, offsetForCaret indicates the top-center offset
        // except the first text which is always (0, 0)
        Offset topLeftOffset = Offset(
            offset.dx +
                offsetForCaret.dx -
                (textOffset == 0 ? 0 : textSpan.width / 2),
            offset.dy + offsetForCaret.dy);
        debugPrint(
            "_RealRichRenderParagraph ImageSpan, textOffset = $textOffset, offsetForCaret=$offsetForCaret, topLeftOffset=$topLeftOffset");

        // if image is not ready: wait for async ImageInfo
        if (textSpan.imageResolver.image == null) {
          textSpan.imageResolver.resolve((imageInfo, synchronousCall) {
            if (synchronousCall) {
              paintImage(
                  canvas: canvas,
                  rect: topLeftOffset & Size(textSpan.width, textSpan.height),
                  image: textSpan.imageResolver.image!,
                  fit: BoxFit.scaleDown,
                  alignment: Alignment.center);
            } else {
              if (owner == null || !owner!.debugDoingPaint) {
                markNeedsPaint();
              }
            }
          });
          textOffset += textSpan.toPlainText().length;
          continue;
        }
        // else: just paint it. bottomCenter Alignment seems better...
        paintImage(
            canvas: canvas,
            rect: topLeftOffset & Size(textSpan.width, textSpan.height),
            image: textSpan.imageResolver.image!,
            fit: BoxFit.scaleDown,
            alignment: Alignment.center);
      }
      textOffset += textSpan.toPlainText().length;
    }

    canvas.restore();
  }
}