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

Flutter中如何创建带节点的不规则波浪曲线路径?

问题描述

我想做一个带节点的不规则波浪曲线路径页面,样式参考设计图。我知道要用CustomPainter的quadraticBezierTo方法,也计划用Stack放置节点,但目前没法做出非正弦的不规则波浪效果。另外节点得满足三个要求:

  • 横轴随机定位(不用手动输入坐标)
  • 垂直间距保持相等
  • App重启后节点位置能保留

节点持久化打算用缓存实现,现在主要卡在波浪路径创建和节点定位上。我试了下面的代码,但没得到预期效果:

class CurvyRoadmapPainter extends CustomPainter {
  final int numberOfNodes;
  final int currentNodeIndex;
  final double animationValue;
  final Function(List<Offset>) onPositionsCalculated;
  final List<Offset> nodePositions = [];

  CurvyRoadmapPainter(
    this.numberOfNodes,
    this.currentNodeIndex,
    this.animationValue,
    this.onPositionsCalculated,
  );

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 5;

    nodePositions.clear();
    final path = Path();
    final segmentHeight = size.height / (numberOfNodes - 1);
    final ydiff = size.height / 5;

    // Starting point (centered at top)
    double startX = size.width * 0.75;
    path.moveTo(startX, 0);
    nodePositions.add(Offset(startX, 0));
    
    for (int i = 1; i < numberOfNodes; i++) {
      final prevY = (i - 1) * segmentHeight;
      final currentY = i * segmentHeight;
      
      final controlX = i.isEven ? size.width * 1.4 : -size.width * 0.4;
      
      final endX = size.width * 0.5;

      path.quadraticBezierTo(
        controlX,
        (prevY + currentY) / 2, // Control point Y remains halfway between nodes
        endX,
        currentY,
      );

      nodePositions.add(Offset(endX, currentY));
    }

    // Draw background path
    paint.color = Colors.white.withOpacity(0.3);
    canvas.drawPath(path, paint);

    // Draw completed path
    if (currentNodeIndex > 0) {
      final completedPath = Path();
      var prevPos = nodePositions[0];

      for (int i = 1; i <= currentNodeIndex; i++) {
        final currentPos = nodePositions[i];
        final controlX = i.isEven ? size.width * 1.4 : -size.width * 0.4;

        completedPath.moveTo(prevPos.dx, prevPos.dy);
        completedPath.quadraticBezierTo(
          controlX,
          (prevPos.dy + currentPos.dy) / 2,
          currentPos.dx,
          currentPos.dy,
        );

        prevPos = currentPos;
      }

      // Animate current segment
      if (currentNodeIndex < numberOfNodes - 1) {
        final nextPos = nodePositions[currentNodeIndex + 1];
        final controlX = (currentNodeIndex + 1).isEven
            ? size.width * 1.4
            : -size.width * 0.4;

        completedPath.moveTo(prevPos.dx, prevPos.dy);
        completedPath.quadraticBezierTo(
          controlX,
          (prevPos.dy + nextPos.dy) / 2,
          lerp(prevPos.dx, nextPos.dx, animationValue),
          lerp(prevPos.dy, nextPos.dy, animationValue),
        );
      }

      paint.color = Colors.green;
      canvas.drawPath(completedPath, paint);
    }

    onPositionsCalculated(nodePositions);
  }

  double lerp(double a, double b, double t) {
    return a + (b - a) * t;
  }

  @override
  bool shouldRepaint(CurvyRoadmapPainter oldDelegate) {
    return oldDelegate.currentNodeIndex != currentNodeIndex ||
        oldDelegate.animationValue != animationValue;
  }
}

解决方案

1. 生成不规则波浪路径

原代码的节点X轴固定、控制点X值规律,导致波浪太规整。要实现不规则效果,需要:

  • 每个节点的X轴在合理范围内随机生成(比如限制在屏幕宽度的20%到80%之间,避免节点跑出可视区域)
  • 控制点的X值基于前后节点的X做随机偏移,让曲线过渡更自然,而非固定的极端值

2. 节点定位与持久化

  • 垂直间距相等:保留原代码segmentHeight = size.height / (numberOfNodes - 1)的计算,每个节点Y坐标为i * segmentHeight即可保证间距一致
  • 随机X轴生成:把节点位置的生成逻辑从paint方法中移出,避免每次重绘都重新生成。初始化时先读取缓存,没有缓存再生成随机值
  • 持久化:用SharedPreferences把节点的坐标序列化为字符串保存,启动时反序列化读取

3. 修正路径绘制逻辑

原代码中绘制已完成路径时,每次循环都调用moveTo会导致路径断开,应该只在起点调用一次moveTo,之后连续用quadraticBezierTo连接节点。

修改后的完整代码示例

1. 持久化工具类

import 'dart:math';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';

class NodeStorage {
  static const String _key = 'roadmap_nodes';

  static Future<List<Offset>> loadNodes(int count, double screenWidth, double screenHeight) async {
    final prefs = await SharedPreferences.getInstance();
    final jsonString = prefs.getString(_key);
    if (jsonString != null) {
      final List<dynamic> list = jsonDecode(jsonString);
      return list.map((e) => Offset(e[0], e[1])).toList();
    }
    // 没有缓存时生成随机节点
    return generateRandomNodes(count, screenWidth, screenHeight);
  }

  static Future<void> saveNodes(List<Offset> nodes) async {
    final prefs = await SharedPreferences.getInstance();
    final List<List<double>> jsonList =
        nodes.map((e) => [e.dx, e.dy]).toList();
    await prefs.setString(_key, jsonEncode(jsonList));
  }

  static List<Offset> generateRandomNodes(int count, double screenWidth, double screenHeight) {
    final nodes = <Offset>[];
    final segmentHeight = screenHeight / (count - 1);
    for (int i = 0; i < count; i++) {
      final y = i * segmentHeight;
      // X轴在20%-80%宽度之间随机
      final x = screenWidth * (0.2 + Random().nextDouble() * 0.6);
      nodes.add(Offset(x, y));
    }
    return nodes;
  }
}

2. 修正后的CustomPainter

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

class CurvyRoadmapPainter extends CustomPainter {
  final int numberOfNodes;
  final int currentNodeIndex;
  final double animationValue;
  final Function(List<Offset>) onPositionsCalculated;
  final List<Offset> nodePositions;

  CurvyRoadmapPainter(
    this.numberOfNodes,
    this.currentNodeIndex,
    this.animationValue,
    this.onPositionsCalculated,
    required this.nodePositions,
  );

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 5;

    // 绘制完整背景路径
    final fullPath = Path();
    if (nodePositions.isNotEmpty) {
      fullPath.moveTo(nodePositions[0].dx, nodePositions[0].dy);
      for (int i = 1; i < nodePositions.length; i++) {
        final prev = nodePositions[i-1];
        final current = nodePositions[i];
        // 计算控制点:X取前后节点X的中间值加随机偏移,Y取中间值
        final controlX = (prev.dx + current.dx) / 2 + (Random().nextDouble() * 100 - 50);
        final controlY = (prev.dy + current.dy) / 2;
        fullPath.quadraticBezierTo(controlX, controlY, current.dx, current.dy);
      }
    }

    paint.color = Colors.white.withOpacity(0.3);
    canvas.drawPath(fullPath, paint);

    // 绘制已完成路径
    if (currentNodeIndex > 0 && nodePositions.isNotEmpty) {
      final completedPath = Path();
      completedPath.moveTo(nodePositions[0].dx, nodePositions[0].dy);

      // 绘制已完成的节点段
      for (int i = 1; i <= currentNodeIndex; i++) {
        final prev = nodePositions[i-1];
        final current = nodePositions[i];
        final controlX = (prev.dx + current.dx) / 2 + (Random().nextDouble() * 100 - 50);
        final controlY = (prev.dy + current.dy) / 2;
        completedPath.quadraticBezierTo(controlX, controlY, current.dx, current.dy);
      }

      // 绘制当前正在动画的段
      if (currentNodeIndex < nodePositions.length - 1) {
        final prev = nodePositions[currentNodeIndex];
        final next = nodePositions[currentNodeIndex + 1];
        final controlX = (prev.dx + next.dx) / 2 + (Random().nextDouble() * 100 - 50);
        final controlY = (prev.dy + next.dy) / 2;
        // 动画插值当前点
        final animatedX = lerp(prev.dx, next.dx, animationValue);
        final animatedY = lerp(prev.dy, next.dy, animationValue);
        completedPath.quadraticBezierTo(controlX, controlY, animatedX, animatedY);
      }

      paint.color = Colors.green;
      canvas.drawPath(completedPath, paint);
    }

    onPositionsCalculated(nodePositions);
  }

  double lerp(double a, double b, double t) {
    return a + (b - a) * t;
  }

  @override
  bool shouldRepaint(CurvyRoadmapPainter oldDelegate) {
    return oldDelegate.currentNodeIndex != currentNodeIndex ||
        oldDelegate.animationValue != animationValue ||
        oldDelegate.nodePositions != nodePositions;
  }
}

3. 在页面中使用

import 'package:flutter/material.dart';

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

  @override
  State<RoadmapPage> createState() => _RoadmapPageState();
}

class _RoadmapPageState extends State<RoadmapPage> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  int currentNodeIndex = 0;
  List<Offset> nodePositions = [];
  final int numberOfNodes = 5;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    );
    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
    WidgetsBinding.instance.addPostFrameCallback((_) => _loadNodes());
  }

  Future<void> _loadNodes() async {
    final size = MediaQuery.of(context).size;
    nodePositions = await NodeStorage.loadNodes(numberOfNodes, size.width, size.height);
    // 如果是新生成的节点,保存到缓存
    await NodeStorage.saveNodes(nodePositions);
    setState(() {});
  }

  void _nextNode() {
    if (currentNodeIndex < numberOfNodes - 1) {
      setState(() {
        currentNodeIndex++;
      });
      _controller.reset();
      _controller.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    return Scaffold(
      backgroundColor: Colors.grey[900],
      body: Stack(
        children: [
          CustomPaint(
            size: size,
            painter: CurvyRoadmapPainter(
              numberOfNodes,
              currentNodeIndex,
              _animation.value,
              (positions) => nodePositions = positions,
              nodePositions: nodePositions,
            ),
          ),
          // 绘制节点
          if (nodePositions.isNotEmpty)
            ...nodePositions.asMap().entries.map((entry) {
              final index = entry.key;
              final pos = entry.value;
              return Positioned(
                left: pos.dx - 15,
                top: pos.dy - 15,
                child: Container(
                  width: 30,
                  height: 30,
                  decoration: BoxDecoration(
                    color: index <= currentNodeIndex ? Colors.green : Colors.white,
                    shape: BoxShape.circle,
                  ),
                  child: Center(
                    child: Text(
                      '${index+1}',
                      style: const TextStyle(color: Colors.black),
                    ),
                  ),
                ),
              );
            }),
          Positioned(
            bottom: 50,
            left: 0,
            right: 0,
            child: Center(
              child: ElevatedButton(
                onPressed: _nextNode,
                child: const Text('Next Node'),
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 23:15:54