Flutter 文字环绕

2,478 阅读4分钟

文字环绕

需求

最近接到一个需求,类似于文字环绕,标题最多两行,超出省略,标题后面可以添加标签。效果如下:

Simulator Screen Shot - iPhone 13 - 2022-02-24 at 16.49.54.png

富文本不能控制省略和折行,Flutter 提供了 TextPainter可以实现。

分析

标签有文字和颜色两个属性,个数不定:

class Tag {

  /// 标签文本
  final String label;
  /// 标签背景颜色
  final Color color;

  Tag({required this.label, required this.color});
}

标题最大行数可变,可能明天产品要最多显示三行;

文本样式可变;

先创建出来对应的Widget

class TagTitle extends StatefulWidget {
  const TagTitle(
    this.text, {
    Key? key,
    required this.tags,
    this.maxLines = 2,
    this.style = const TextStyle(color: Colors.black, fontSize: 16),
  }) : super(key: key);
  
  final String text;
  final int maxLines;
  final TextStyle style;
  final List<Tag> tags;
  }

实现

标题文字和标签文字有两种显示情况:

  1. 超出最大行数;
  2. 未超出最大行数;

先假设第一种情况,因为标签前后有间距,所以每个标签前后补一个空格,再把标题和文字拼接创建对应的TextSpan

    tagTexts = widget.tags.fold<String>(
        ' ', (previousValue, element) => '$previousValue${element.label} ');
        
    _allSp = TextSpan(
      text: '${widget.text}$tagTexts',
      style: widget.style,
    );
        

要绘制标题、省略号、标签、都需要TextSpan,所以一并创建出来,当然还有最重要的TextPainter

// 标签
final tagsSp = TextSpan(
  text: tagTexts,
  style: widget.style,
);

// 省略号
final ellipsizeTextSp = TextSpan(
  text: ellipsizeText,
  style: widget.style,
);

// 标题
final textSp = TextSpan(
  text: widget.text,
  style: widget.style,
);

final textPainter = TextPainter(
  text: tagsSp,
  textDirection: TextDirection.ltr,
  maxLines: widget.maxLines,
)..layout(
    minWidth: constraints.minWidth,
    maxWidth: constraints.maxWidth,
  );

拿到标签、省略号、标题的尺寸:

final tagsSize = textPainter.size;

textPainter.text = ellipsizeTextSp;
textPainter.layout(
  minWidth: constraints.minWidth,
  maxWidth: constraints.maxWidth,
);

final ellipsizeSize = textPainter.size;

textPainter.text = textSp;
textPainter.layout(
  minWidth: constraints.minWidth,
  maxWidth: constraints.maxWidth,
);
final textSize = textPainter.size;

算出标题超出最大长度的位置:

textPainter.text = _allSp;
textPainter.layout(
  minWidth: constraints.minWidth,
  maxWidth: constraints.maxWidth,
);

final pos = textPainter.getPositionForOffset(Offset(
  textSize.width - tagsSize.width - ellipsizeSize.width,
  textSize.height,
));

final endIndex = textPainter.getOffsetBefore(pos.offset);

如果超出的话,文字显示区域的宽度减去标签宽度减去省略号宽度,剩下的位置就是标题最大宽度偏移量,根据偏移量得到此位置的文字位置下标。

textPainter.didExceedMaxLines返回的是是否超出最大长度,也就是一开始分析的两种情况的哪一种,如果超出,就根据上面计算出来的下标截取标题文字,添加省略号,然后添加上标签;否则,直接显示标题文本和标签:

TextSpan textSpan;

if (textPainter.didExceedMaxLines) {
  textSpan = TextSpan(
    style: widget.style,
    text: widget.text.substring(0, endIndex) + ellipsizeText,
    children: _getWidgetSpan(),
  );
} else {
  textSpan = TextSpan(
    style: widget.style,
    text: widget.text,
    children: _getWidgetSpan(),
  );
}
return RichText(
  text: textSpan,
  overflow: TextOverflow.ellipsis,
  maxLines: widget.maxLines,
);

标签因为带有背景,所以可以用WidgetSpan加上标签背景,这里使用CustomPaint实现:

List<WidgetSpan> _getWidgetSpan() {
  return widget.tags
      .map((e) => WidgetSpan(
            child: CustomPaint(
              painter: BgPainter(e.color),
              child: Text(
                ' ' + e.label + ' ',
                style: widget.style,
              ),
            ),
          ))
      .toList();
}

这个BgPainter就一个功能,绘制背景色:

class BgPainter extends CustomPainter {
  final Paint _painter;

  final Color color;

  BgPainter(this.color) : _painter = Paint()..color = color;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Rect.fromLTWH(2, 0, size.width - 2, size.height), _painter);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) =>
      oldDelegate != this;
}

使用:

TagTitle(
  '据法新社报道,法国总统府爱丽舍宫发布消息称,法国',
  tags: [
    Tag(label: '热门', color: Colors.red),
    Tag(label: '国际', color: Colors.blue),
  ],
),
const Divider(
  color: Color(0xFF167F67),
),
TagTitle(
  '据法新社报道,法国总统府爱丽舍宫发布消息称,法国总统马克龙提议俄罗斯总统普京和美国总统sadaasdadadada',
  tags: [
    Tag(label: '热门', color: Colors.red),
    Tag(label: '国际', color: Colors.blue),
  ],
),
const Divider(
  color: Color(0xFF167F67),
),
TagTitle(
  '据法新社报道,法国总统府爱丽舍宫发布',
  tags: [
    Tag(label: '热门', color: Colors.red),
    Tag(label: '国际', color: Colors.blue),
  ],
),
const Divider(
  color: Color(0xFF167F67),
),
TagTitle(
  '据法新社报道,法国总统府爱丽舍宫发布消息称,法国总统马克龙提议俄罗',
  tags: [
    Tag(label: '热门', color: Colors.red),
    Tag(label: '国际', color: Colors.blue),
  ],
),

附上完整代码:

main.dart

import 'package:custom/review/tag_title.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          TagTitle(
            '据法新社报道,法国总统府爱丽舍宫发布消息称,法国',
            tags: [
              Tag(label: '热门', color: Colors.red),
              Tag(label: '国际', color: Colors.blue),
            ],
          ),
          const Divider(
            color: Color(0xFF167F67),
          ),
          TagTitle(
            '据法新社报道,法国总统府爱丽舍宫发布消息称,法国总统马克龙提议俄罗斯总统普京和美国总统sadaasdadadada',
            tags: [
              Tag(label: '热门', color: Colors.red),
              Tag(label: '国际', color: Colors.blue),
            ],
          ),
          const Divider(
            color: Color(0xFF167F67),
          ),
          TagTitle(
            '据法新社报道,法国总统府爱丽舍宫发布',
            tags: [
              Tag(label: '热门', color: Colors.red),
              Tag(label: '国际', color: Colors.blue),
            ],
          ),
          const Divider(
            color: Color(0xFF167F67),
          ),
          TagTitle(
            '据法新社报道,法国总统府爱丽舍宫发布消息称,法国总统马克龙提议俄罗',
            tags: [
              Tag(label: '热门', color: Colors.red),
              Tag(label: '国际', color: Colors.blue),
            ],
          ),
        ],
      ),
    );
  }
}

Tag_title.dart:

//@dart=2.12
import 'package:flutter/material.dart';

import 'bg_painter.dart';

class TagTitle extends StatefulWidget {
  const TagTitle(
    this.text, {
    Key? key,
    required this.tags,
    this.maxLines = 2,
    this.style = const TextStyle(color: Colors.black, fontSize: 16),
  }) : super(key: key);

  final String text;
  final int maxLines;
  final TextStyle style;
  final List<Tag> tags;

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

class TagTitleState extends State<TagTitle> {
  late final String tagTexts;
  late final TextSpan _allSp;
  final String ellipsizeText = '...';

  @override
  void initState() {
    super.initState();
    tagTexts = widget.tags.fold<String>(
        ' ', (previousValue, element) => '$previousValue${element.label} ');
    _allSp = TextSpan(
      text: '${widget.text}$tagTexts',
      style: widget.style,
    );
  }

  List<WidgetSpan> _getWidgetSpan() {
    return widget.tags
        .map((e) => WidgetSpan(
              child: CustomPaint(
                painter: BgPainter(e.color),
                child: Text(
                  ' ' + e.label + ' ',
                  style: widget.style,
                ),
              ),
            ))
        .toList();
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        assert(constraints.hasBoundedWidth);
        // 标签
        final tagsSp = TextSpan(
          text: tagTexts,
          style: widget.style,
        );

        // 省略号
        final ellipsizeTextSp = TextSpan(
          text: ellipsizeText,
          style: widget.style,
        );

        // 标题
        final textSp = TextSpan(
          text: widget.text,
          style: widget.style,
        );

        final textPainter = TextPainter(
          text: tagsSp,
          textDirection: TextDirection.ltr,
          maxLines: widget.maxLines,
        )..layout(
            minWidth: constraints.minWidth,
            maxWidth: constraints.maxWidth,
          );
        final tagsSize = textPainter.size;

        textPainter.text = ellipsizeTextSp;
        textPainter.layout(
          minWidth: constraints.minWidth,
          maxWidth: constraints.maxWidth,
        );

        final ellipsizeSize = textPainter.size;

        textPainter.text = textSp;
        textPainter.layout(
          minWidth: constraints.minWidth,
          maxWidth: constraints.maxWidth,
        );
        final textSize = textPainter.size;

        textPainter.text = _allSp;
        textPainter.layout(
          minWidth: constraints.minWidth,
          maxWidth: constraints.maxWidth,
        );

        final pos = textPainter.getPositionForOffset(Offset(
          textSize.width - tagsSize.width - ellipsizeSize.width,
          textSize.height,
        ));

        final endIndex = textPainter.getOffsetBefore(pos.offset);

        TextSpan textSpan;

        if (textPainter.didExceedMaxLines) {
          textSpan = TextSpan(
            style: widget.style,
            text: widget.text.substring(0, endIndex) + ellipsizeText,
            children: _getWidgetSpan(),
          );
        } else {
          textSpan = TextSpan(
            style: widget.style,
            text: widget.text,
            children: _getWidgetSpan(),
          );
        }
        return RichText(
          text: textSpan,
          overflow: TextOverflow.ellipsis,
          maxLines: widget.maxLines,
        );
      },
    );
  }
}
class Tag {

  /// 标签文本
  final String label;
  /// 标签背景颜色
  final Color color;

  Tag({required this.label, required this.color});
}

bg_painter.dart:

//@dart=2.12
import 'dart:ui' as ui;

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

class BgPainter extends CustomPainter {
  final Paint _painter;

  final Color color;

  BgPainter(this.color) : _painter = Paint()..color = color;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Rect.fromLTWH(2, 0, size.width - 2, size.height), _painter);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) =>
      oldDelegate != this;
}