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

Flutter如何实现如图所示的带嵌入文字的容器边框?

实现带嵌入文字的圆角边框(Flutter)

问题背景

我需要实现Figma设计中那种顶部嵌入文字的圆角边框效果,试过StackOverflow上的几种方案:

  • Flutter, how to create border with text embedded?
  • Text through border in flutter
    但这些方案在添加borderRadius或把顶部边框设为透明时,边框无法正常显示。也尝试过用Paint组件的方案,比如Flutter border around container with text in the middle (divider?),但自己写的代码效果不符合预期。

当前代码

Home组件代码

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

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  final description =
      'Lorem ipsum dolor sit amet consectetur. Sed auctor dignissim sit egestas dolor turpis. Nunc congue vulputate tincidunt purus. Non lacus metus tortor elit mauris proin.';

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return SingleChildScrollView(
      child: Column(
        children: [
          FittedBox(
              child: topSection(header: headerWidget(), body: bodyWidget())),
          const SizedBox(height: 20),
          //midSection(),
          CustomTitleWidget(
              height: 200,
              width: double.infinity,
              title: 'Some of my Projects',
              radius: 20,
          ),
        ],
      ),
    );
  }
}

自定义边框组件代码(效果不符合预期)

class CustomDraw extends CustomPainter {
  late Paint painter;
  late double radius;
  late double textWidth;

  CustomDraw(Color color, this.textWidth, {this.radius = 0}) {
    painter = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2
      ..color = color;
  }

  @override
  void paint(Canvas canvas, Size size) {
    var path = Path();

    path.moveTo(size.width - ((size.width - textWidth) / 2), 0);

    path.lineTo(size.width - radius, 0);
    path.cubicTo(size.width - radius, 0, size.width, 0, size.width, radius);
    path.lineTo(size.width, size.height - radius);
    path.cubicTo(size.width, size.height - radius, size.width, size.height,
        size.width - radius, size.height);

    path.lineTo(radius, size.height);
    path.cubicTo(radius, size.height, 0, size.height, 0, size.height - radius);

    path.lineTo(0, radius);
    path.cubicTo(0, radius, 0, 0, radius, 0);
    path.lineTo(((size.width - textWidth) / 2), 0);

    canvas.drawPath(path, painter);
  }

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

class CustomTitleWidget extends StatefulWidget {
  final double height;
  final double width;
  final double? radius;
  final String title;
  const CustomTitleWidget(
      {Key? key,
        required this.height,
        required this.width,
        required this.title,
        this.radius})
      : super(key: key);

  @override
  State<CustomTitleWidget> createState() => _CustomTitleWidgetState();
}

class _CustomTitleWidgetState extends State<CustomTitleWidget> {
  GlobalKey textKey = GlobalKey();
  double textHeight = 0.0;
  double textWidth = 0.0;

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

    WidgetsBinding.instance.addPostFrameCallback((_) {
      setState(() {
        final textKeyContext = textKey.currentContext;
        if (textKeyContext != null) {
          final box = textKeyContext.findRenderObject() as RenderBox;
          textHeight = box.size.height;
          textWidth = box.size.width;
        }
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      clipBehavior: Clip.none,
      alignment: Alignment.topCenter,
      children: [
        CustomPaint(
          painter: CustomDraw(
            Colors.black,
            textWidth,
            radius: widget.radius ?? 0,
          ),
          child: SizedBox(
            height: widget.height,
            width: widget.width,
          ),
        ),
        Positioned(
          top: -textHeight / 2,
          left: 60,
          child: Padding(
            key: textKey,
            padding: EdgeInsets.symmetric(horizontal: 8.0),
            child: Container(
              color: Colors.transparent,
              padding: const EdgeInsets.symmetric(horizontal: 10),
              child: Text(
                widget.title,
                style: AppTextStyles.nameTitle,
              ),
            ),
          ),
        )
      ],
    );
  }
}

问题分析与修复方案

核心问题

  1. 原CustomDraw路径计算错误,导致顶部边框缺口位置偏移、圆角衔接不自然
  2. 文字容器背景设为透明,无法覆盖下方边框线,出现文字与边框重叠的情况
  3. 圆角路径用cubicTo绘制不规范,容易出现边框缺口

修正后的代码

1. 修复CustomDraw的路径绘制

class CustomDraw extends CustomPainter {
  final Paint painter;
  final double radius;
  final double textWidth;
  final double strokeWidth;

  CustomDraw(Color color, this.textWidth, {this.radius = 0, this.strokeWidth = 2})
      : painter = Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = strokeWidth
          ..color = color;

  @override
  void paint(Canvas canvas, Size size) {
    final path = Path();
    final halfStroke = strokeWidth / 2;
    final textGapStart = (size.width - textWidth) / 2;
    final textGapEnd = size.width - textGapStart;

    // 从文字缺口右侧开始,绘制右上角圆角
    path.moveTo(textGapEnd, halfStroke);
    path.lineTo(size.width - radius, halfStroke);
    path.arcToPoint(
      Offset(size.width - halfStroke, radius + halfStroke),
      radius: Radius.circular(radius),
      clockwise: false,
    );

    // 绘制右侧边框
    path.lineTo(size.width - halfStroke, size.height - radius - halfStroke);
    path.arcToPoint(
      Offset(size.width - radius - halfStroke, size.height - halfStroke),
      radius: Radius.circular(radius),
      clockwise: false,
    );

    // 绘制底部边框
    path.lineTo(radius + halfStroke, size.height - halfStroke);
    path.arcToPoint(
      Offset(halfStroke, size.height - radius - halfStroke),
      radius: Radius.circular(radius),
      clockwise: false,
    );

    // 绘制左侧边框
    path.lineTo(halfStroke, radius + halfStroke);
    path.arcToPoint(
      Offset(radius + halfStroke, halfStroke),
      radius: Radius.circular(radius),
      clockwise: false,
    );

    // 绘制文字缺口左侧的顶部边框
    path.lineTo(textGapStart, halfStroke);

    canvas.drawPath(path, painter);
  }

  @override
  bool shouldRepaint(covariant CustomDraw oldDelegate) {
    return oldDelegate.radius != radius ||
        oldDelegate.textWidth != textWidth ||
        oldDelegate.strokeWidth != strokeWidth ||
        oldDelegate.painter.color != painter.color;
  }
}

2. 修复CustomTitleWidget的文字容器

class CustomTitleWidget extends StatefulWidget {
  final double height;
  final double width;
  final double? radius;
  final String title;
  final TextStyle? titleStyle;
  final Color borderColor;
  final double borderWidth;
  final Color backgroundColor;

  const CustomTitleWidget({
    Key? key,
    required this.height,
    required this.width,
    required this.title,
    this.radius,
    this.titleStyle,
    this.borderColor = Colors.black,
    this.borderWidth = 2,
    this.backgroundColor = Colors.white,
  }) : super(key: key);

  @override
  State<CustomTitleWidget> createState() => _CustomTitleWidgetState();
}

class _CustomTitleWidgetState extends State<CustomTitleWidget> {
  final GlobalKey textKey = GlobalKey();
  double textWidth = 0.0;
  double textHeight = 0.0;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final renderBox = textKey.currentContext?.findRenderObject() as RenderBox?;
      if (renderBox != null) {
        setState(() {
          textWidth = renderBox.size.width;
          textHeight = renderBox.size.height;
        });
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      clipBehavior: Clip.none,
      alignment: Alignment.topCenter,
      children: [
        CustomPaint(
          painter: CustomDraw(
            widget.borderColor,
            textWidth,
            radius: widget.radius ?? 0,
            strokeWidth: widget.borderWidth,
          ),
          child: SizedBox(
            height: widget.height,
            width: widget.width,
          ),
        ),
        Positioned(
          top: -textHeight / 2,
          child: Container(
            key: textKey,
            color: widget.backgroundColor,
            padding: const EdgeInsets.symmetric(horizontal: 10),
            child: Text(
              widget.title,
              style: widget.titleStyle ?? const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
            ),
          ),
        ),
      ],
    );
  }
}

修复说明

  1. 路径优化:用arcToPoint替代cubicTo绘制圆角,同时考虑边框宽度偏移,避免圆角缺口
  2. 背景覆盖:将文字容器背景设为页面背景色,完全覆盖下方边框线,实现嵌入效果
  3. 参数扩展:增加边框颜色、宽度、背景色、文字样式等可配置项,提升组件复用性
  4. 性能优化:更新shouldRepaint逻辑,仅在参数变化时重绘

内容的提问来源于stack exchange,提问作者Ömer Faruk Demirsoy

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.21 12:53:12