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

