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

Flutter中如何创建弧形分段按钮?解决点击与文字弯曲问题

Flutter环形分段按钮组实现方案(解决点击区域与文字弯曲问题)

核心问题解决思路

1. 精准点击区域判断

CustomPainter仅负责绘制,不会自动识别形状点击区域。解决方法是给每个分段单独绑定GestureDetector,并通过Path.contains()方法校验点击坐标是否落在弧形路径内:

  • 为每个分段预先计算对应的弧形Path
  • 在GestureDetector的onTapDown回调中,将全局点击坐标转换为组件局部坐标
  • 用path.contains(localPosition)判断是否触发点击事件

2. 弯曲文字实现

Flutter原生无直接弯曲文本API,两种可行方案:

  • 第三方包实现:使用arc_text,只需指定半径、起始角度等参数,即可快速生成沿圆弧排列的文本
  • 自定义绘制:通过CustomPainter遍历文本字符,计算每个字符在圆弧上的位置和旋转角度,逐个绘制

3. 悬停凸起效果

用MouseRegion监听鼠标悬停状态,通过改变弧形外半径或添加偏移量实现视觉凸起,配合AnimatedBuilder可让过渡更平滑

代码片段示例

import 'package:flutter/material.dart';
// 若用第三方包需添加依赖后导入:import 'package:arc_text/arc_text.dart';

class SegmentButton extends StatefulWidget {
  final double startAngle;
  final double sweepAngle;
  final String text;
  final VoidCallback onTap;
  final double outerRadius;
  final double innerRadius;

  const SegmentButton({
    super.key,
    required this.startAngle,
    required this.sweepAngle,
    required this.text,
    required this.onTap,
    this.outerRadius = 150,
    this.innerRadius = 100,
  });

  @override
  State<SegmentButton> createState() => _SegmentButtonState();
}

class _SegmentButtonState extends State<SegmentButton> {
  bool _isHovered = false;

  @override
  Widget build(BuildContext context) {
    final hoverOffset = _isHovered ? 5.0 : 0.0;
    final currentOuterRadius = widget.outerRadius + hoverOffset;

    return MouseRegion(
      onEnter: (_) => setState(() => _isHovered = true),
      onExit: (_) => setState(() => _isHovered = false),
      child: GestureDetector(
        onTapDown: (details) {
          final renderBox = context.findRenderObject() as RenderBox;
          final localPos = renderBox.globalToLocal(details.globalPosition);
          final path = _getSegmentPath(currentOuterRadius, widget.innerRadius);
          if (path.contains(localPos)) {
            widget.onTap();
          }
        },
        child: CustomPaint(
          painter: SegmentPainter(
            startAngle: widget.startAngle,
            sweepAngle: widget.sweepAngle,
            outerRadius: currentOuterRadius,
            innerRadius: widget.innerRadius,
            color: _isHovered ? Colors.blue.shade300 : Colors.blue.shade500,
          ),
          child: SizedBox(
            width: currentOuterRadius * 2,
            height: currentOuterRadius * 2,
            child: Center(
              // 方案1:用arc_text包实现弯曲文字
              // child: ArcText(
              //   radius: currentOuterRadius - 20,
              //   text: widget.text,
              //   textStyle: const TextStyle(color: Colors.white, fontSize: 16),
              //   startAngle: widget.startAngle + widget.sweepAngle / 2,
              //   startAngleAlignment: StartAngleAlignment.center,
              // ),
              // 方案2:使用自定义绘制(需在SegmentPainter中实现)
              child: const SizedBox.shrink(),
            ),
          ),
        ),
      ),
    );
  }

  Path _getSegmentPath(double outerRadius, double innerRadius) {
    final center = Offset(outerRadius, outerRadius);
    return Path()
      ..moveTo(
        center.dx + outerRadius * cos(widget.startAngle),
        center.dy + outerRadius * sin(widget.startAngle),
      )
      ..arcTo(
        Rect.fromCircle(center: center, radius: outerRadius),
        widget.startAngle,
        widget.sweepAngle,
        false,
      )
      ..lineTo(
        center.dx + innerRadius * cos(widget.startAngle + widget.sweepAngle),
        center.dy + innerRadius * sin(widget.startAngle + widget.sweepAngle),
      )
      ..arcTo(
        Rect.fromCircle(center: center, radius: innerRadius),
        widget.startAngle + widget.sweepAngle,
        -widget.sweepAngle,
        false,
      )
      ..close();
  }
}

class SegmentPainter extends CustomPainter {
  final double startAngle;
  final double sweepAngle;
  final double outerRadius;
  final double innerRadius;
  final Color color;

  SegmentPainter({
    required this.startAngle,
    required this.sweepAngle,
    required this.outerRadius,
    required this.innerRadius,
    required this.color,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final paint = Paint()..color = color;

    final path = Path()
      ..moveTo(
        center.dx + outerRadius * cos(startAngle),
        center.dy + outerRadius * sin(startAngle),
      )
      ..arcTo(
        Rect.fromCircle(center: center, radius: outerRadius),
        startAngle,
        sweepAngle,
        false,
      )
      ..lineTo(
        center.dx + innerRadius * cos(startAngle + sweepAngle),
        center.dy + innerRadius * sin(startAngle + sweepAngle),
      )
      ..arcTo(
        Rect.fromCircle(center: center, radius: innerRadius),
        startAngle + sweepAngle,
        -sweepAngle,
        false,
      )
      ..close();

    canvas.drawPath(path, paint);

    // 自定义绘制弯曲文字(可选)
    _drawCurvedText(canvas, center);
  }

  void _drawCurvedText(Canvas canvas, Offset center) {
    final text = "按钮文字";
    final textStyle = const TextStyle(color: Colors.white, fontSize: 16);
    final textPainter = TextPainter(
      text: TextSpan(text: text, style: textStyle),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();

    final radius = outerRadius - 20;
    final anglePerChar = sweepAngle / text.length;
    final startAngle = this.startAngle + anglePerChar / 2;

    for (int i = 0; i < text.length; i++) {
      final char = text[i];
      final charPainter = TextPainter(
        text: TextSpan(text: char, style: textStyle),
        textDirection: TextDirection.ltr,
      );
      charPainter.layout();

      final angle = startAngle + i * anglePerChar;
      final x = center.dx + radius * cos(angle) - charPainter.width / 2;
      final y = center.dy + radius * sin(angle) - charPainter.height / 2;

      canvas.save();
      canvas.translate(x, y);
      canvas.rotate(angle + pi / 2); // 旋转文字使其沿圆弧垂直
      charPainter.paint(canvas, Offset.zero);
      canvas.restore();
    }
  }

  @override
  bool shouldRepaint(covariant SegmentPainter oldDelegate) {
    return oldDelegate.startAngle != startAngle ||
        oldDelegate.sweepAngle != sweepAngle ||
        oldDelegate.outerRadius != outerRadius ||
        oldDelegate.color != color;
  }
}

// 使用示例
class CircularButtonGroup extends StatelessWidget {
  const CircularButtonGroup({super.key});

  @override
  Widget build(BuildContext context) {
    final segments = 6;
    final sweepAngle = 2 * pi / segments;
    return Stack(
      alignment: Alignment.center,
      children: List.generate(segments, (index) {
        final startAngle = -pi / 2 + index * sweepAngle;
        return SegmentButton(
          startAngle: startAngle,
          sweepAngle: sweepAngle,
          text: "按钮${index + 1}",
          onTap: () => print("点击按钮${index + 1}"),
        );
      }),
    );
  }
}

推荐第三方包

  • arc_text:专注于实现沿圆弧排列的文本,配置简单
  • circular_menu:封装好的环形菜单组件,可直接用于快速搭建分段按钮组

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.16 15:58:18