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

Flutter:如何在外围圆形与中心圆形间绘制连接线

Flutter 实现外围圆形到中心圆形的连接线方案

先修复你的LinePainterWidget,原组件存在几个问题:CustomPaint没有尺寸约束导致线条无法渲染、shouldRepaint一直返回true造成不必要重绘、直接使用全局坐标容易受布局影响。修改后的代码如下:

import 'package:flutter/material.dart';

class LinePainterWidget extends StatelessWidget {
  final Offset start;
  final Offset end;
  final Color color;
  final double strokeWidth;

  const LinePainterWidget({
    super.key,
    required this.start,
    required this.end,
    this.color = Colors.white,
    this.strokeWidth = 2.0,
  });

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: Size.infinite, // 让CustomPaint占满父容器,确保线条能渲染
      painter: MyLinePainter(
        start: start,
        end: end,
        color: color,
        strokeWidth: strokeWidth,
      ),
    );
  }
}

class MyLinePainter extends CustomPainter {
  final Offset start;
  final Offset end;
  final Color color;
  final double strokeWidth;

  MyLinePainter({
    required this.start,
    required this.end,
    required this.color,
    required this.strokeWidth,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round; // 线条两端圆润,视觉效果更好

    canvas.drawLine(start, end, paint);
  }

  @override
  bool shouldRepaint(covariant MyLinePainter oldDelegate) {
    // 仅当参数变化时触发重绘,优化性能
    return oldDelegate.start != start ||
        oldDelegate.end != end ||
        oldDelegate.color != color ||
        oldDelegate.strokeWidth != strokeWidth;
  }
}

接下来重构HomePage布局,原布局用Column和Row排列圆形,无法直接获取相对位置,改用Stack布局承载线条和圆形,线条放在底层,圆形在上层。通过GlobalKey获取每个圆形的位置,计算中心坐标后绘制连接线:

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  // 定义每个圆形组件的GlobalKey,用于获取布局位置
  final GlobalKey _centerCircleKey = GlobalKey();
  final GlobalKey _topCircleKey = GlobalKey();
  final GlobalKey _topLeftCircleKey = GlobalKey();
  final GlobalKey _topRightCircleKey = GlobalKey();
  final GlobalKey _bottomLeftCircleKey = GlobalKey();
  final GlobalKey _bottomRightCircleKey = GlobalKey();
  final GlobalKey _bottomCircleKey = GlobalKey();

  Offset? _centerOffset; // 中心圆的局部中心坐标
  final Map<GlobalKey, Offset?> _outerOffsets = {}; // 外围圆的坐标存储

  @override
  void initState() {
    super.initState();
    // 初始化外围圆坐标存储
    _outerOffsets[_topCircleKey] = null;
    _outerOffsets[_topLeftCircleKey] = null;
    _outerOffsets[_topRightCircleKey] = null;
    _outerOffsets[_bottomLeftCircleKey] = null;
    _outerOffsets[_bottomRightCircleKey] = null;
    _outerOffsets[_bottomCircleKey] = null;

    // 布局完成后计算所有圆形的中心坐标
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _calculateCircleCenters();
    });
  }

  // 计算所有圆形的中心局部坐标
  void _calculateCircleCenters() {
    setState(() {
      _centerOffset = _getCircleCenter(_centerCircleKey);
      for (var key in _outerOffsets.keys) {
        _outerOffsets[key] = _getCircleCenter(key);
      }
    });
  }

  // 根据GlobalKey获取组件的局部中心坐标
  Offset? _getCircleCenter(GlobalKey key) {
    final renderBox = key.currentContext?.findRenderObject() as RenderBox?;
    if (renderBox == null) return null;
    final size = renderBox.size;
    return Offset(size.width / 2, size.height / 2);
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async => false,
      child: Scaffold(
        drawer: AppDrawer(),
        body: Container(
          color: const Color(0xFF191D3F),
          child: SafeArea(
            child: Column(
              children: [
                const SizedBox(height: 10),
                const HomeTopBar(username: "User001"),
                Flexible(
                  child: SingleChildScrollView(
                    child: SizedBox(
                      width: double.infinity,
                      child: Stack(
                        alignment: Alignment.center,
                        children: [
                          // 绘制连接线:放在Stack底层,避免被圆形遮挡
                          if (_centerOffset != null)
                            ..._outerOffsets.entries.map((entry) {
                              final outerOffset = entry.value;
                              if (outerOffset != null) {
                                return LinePainterWidget(
                                  start: _centerOffset!,
                                  end: outerOffset,
                                );
                              }
                              return const SizedBox.shrink();
                            }),

                          // 所有圆形组件:放在线条上层
                          Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              const SizedBox(height: 20),
                              GestureDetector(
                                onTapDown: (details) {
                                  final tapPosition = details.globalPosition;
                                  print("Top");
                                  print("x :${tapPosition.dx}");
                                  print("y :${tapPosition.dy}");
                                },
                                child: CircleIconWidget(
                                  key: _topCircleKey,
                                  width: 100,
                                  imagePath: 'Assets/solarIcon-Live.png',
                                  color: Colors.yellow,
                                  labelText: '100 W',
                                ),
                              ),
                              Padding(
                                padding: const EdgeInsets.symmetric(horizontal: 20),
                                child: Row(
                                  mainAxisAlignment: MainAxisAlignment.center,
                                  children: [
                                    GestureDetector(
                                      onTapDown: (details) {
                                        final tapPosition = details.globalPosition;
                                        print("Top Left");
                                        print("x :${tapPosition.dx}");
                                        print("y :${tapPosition.dy}");
                                      },
                                      onTap: () => Navigator.of(context)
                                          .push(MaterialPageRoute(
                                              builder: (context) => const EVChargerView(
                                                    number: 1,
                                                  ))),
                                      child: CircleIconWidget(
                                        key: _topLeftCircleKey,
                                        width: 100,
                                        imagePath: 'Assets/EVChargerIcon-Live.png',
                                        color: Colors.green,
                                        labelText: '100 W',
                                      ),
                                    ),
                                    const Spacer(),
                                    GestureDetector(
                                      onTapDown: (details) {
                                        final tapPosition = details.globalPosition;
                                        print("Top Right");
                                        print("x :${tapPosition.dx}");
                                        print("y :${tapPosition.dy}");
                                      },
                                      onTap: () => Navigator.of(context)
                                          .push(MaterialPageRoute(
                                              builder: (context) => const EVChargerView(
                                                    number: 2,
                                                  ))),
                                      child: CircleIconWidget(
                                        key: _topRightCircleKey,
                                        width: 100,
                                        imagePath: 'Assets/EVChargerIcon-Live.png',
                                        color: Colors.green,
                                        labelText: '100 W',
                                      ),
                                    ),
                                  ],
                                ),
                              ),
                              GestureDetector(
                                onTapDown: (details) {
                                  final tapPosition = details.globalPosition;
                                  print("middle");
                                  print("x :${tapPosition.dx}");
                                  print("y :${tapPosition.dy}");
                                },
                                child: CircleIconWidget(
                                  key: _centerCircleKey,
                                  width: 100,
                                  imagePath: 'Assets/homeIcon-Live.png',
                                  color: Colors.white,
                                  labelText: '100 W',
                                ),
                              ),
                              Padding(
                                padding: const EdgeInsets.symmetric(horizontal: 20),
                                child: Row(
                                  mainAxisAlignment: MainAxisAlignment.center,
                                  children: [
                                    GestureDetector(
                                      onTapDown: (details) {
                                        final tapPosition = details.globalPosition;
                                        print("Bottom Left");
                                        print("x :${tapPosition.dx}");
                                        print("y :${tapPosition.dy}");
                                      },
                                      child: CircleIconWidget(
                                        key: _bottomLeftCircleKey,
                                        width: 100,
                                        imagePath: 'Assets/gridIcon-Live.png',
                                        color: Colors.red,
                                        labelText: '100 W',
                                      ),
                                    ),
                                    const Spacer(),
                                    GestureDetector(
                                      onTapDown: (details) {
                                        final tapPosition = details.globalPosition;
                                        print("Bottom Right");
                                        print("x :${tapPosition.dx}");
                                        print("y :${tapPosition.dy}");
                                      },
                                      child: CircleIconWidget(
                                        key: _bottomRightCircleKey,
                                        width: 100,
                                        imagePath: 'Assets/batteryIcon-Live.png',
                                        color: Colors.blue,
                                        labelText: '100 W',
                                      ),
                                    ),
                                  ],
                                ),
                              ),
                              GestureDetector(
                                onTapDown: (details) {
                                  final tapPosition = details.globalPosition;
                                  print("Bottom Middle");
                                  print("x :${tapPosition.dx}");
                                  print("y :${tapPosition.dy}");
                                },
                                child: CircleIconWidget(
                                  key: _bottomCircleKey,
                                  width: 100,
                                  imagePath: 'Assets/diverterIcon-Live.png',
                                  color: Colors.pink,
                                  labelText: '100 W',
                                ),
                              ),
                              const SizedBox(height: 20),
                            ],
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

关键说明

  • 用Stack布局实现线条和圆形的层叠,线条在底层,确保不会被圆形组件遮挡
  • 通过GlobalKey和RenderBox获取组件的局部中心坐标,避免全局坐标受屏幕尺寸、布局 padding 等因素影响
  • 在addPostFrameCallback中计算坐标,确保组件已经完成布局,能正确获取位置信息
  • 优化LinePainterWidget的重绘逻辑,只在必要时触发重绘,提升页面性能

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.16 02:08:11