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
相关产品推荐
相关产品推荐

