import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(home: const DrawCurve()));
}
class DrawCurve extends StatefulWidget {
const DrawCurve({super.key});
@override
State<DrawCurve> createState() => _DrawCurveState();
}
class _DrawCurveState extends State<DrawCurve> {
static const double _min = 1.5, _max = 5.0;
Path _path = Path();
_BezierBuilder? _builder;
final List<_TouchPoint> _points = <_TouchPoint>[];
bool _hasCurve = false;
final ValueNotifier<int> _tick = ValueNotifier<int>(0);
void _begin(Offset pos, Duration t) {
_builder = _BezierBuilder(
minWidth: _min,
maxWidth: _max,
velocityFilterWeight: 0.2,
)..reset();
_points
..clear()
..add(_TouchPoint(x: pos.dx, y: pos.dy, time: t.inMilliseconds));
_hasCurve = false;
_tick.value++;
}
void _move(Offset pos, Duration t) {
if (_builder == null) return;
final p = _TouchPoint(x: pos.dx, y: pos.dy, time: t.inMilliseconds);
_points.add(p);
final c = _builder!.addPoint(p);
if (c != null) {
_drawCurve(_path, c, _max);
_hasCurve = true;
}
_tick.value++;
}
void _end(Offset pos, Duration t) {
if (_builder == null) return;
final p = _TouchPoint(x: pos.dx, y: pos.dy, time: t.inMilliseconds);
_points.add(p);
final c = _builder!.addPoint(p);
if (c != null) {
_drawCurve(_path, c, _max);
_hasCurve = true;
}
if (!_hasCurve && _points.isNotEmpty) {
final o = Offset(_points.last.x, _points.last.y);
_path.addOval(Rect.fromCircle(center: o, radius: (_min + _max) / 2));
}
_builder = null;
_points.clear();
_hasCurve = false;
_tick.value++;
}
void _clear() {
setState(() {
_path = Path();
_builder = null;
_points.clear();
_hasCurve = false;
_tick.value++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
FloatingActionButton(onPressed: _clear, child: Icon(Icons.refresh)),
],
),
body: Listener(
onPointerDown: (e) => _begin(e.localPosition, e.timeStamp),
onPointerMove: (e) => _move(e.localPosition, e.timeStamp),
onPointerUp: (e) => _end(e.localPosition, e.timeStamp),
child: RepaintBoundary(
child: CustomPaint(
painter: _CurvePainter(
path: _path,
minWidth: _min,
maxWidth: _max,
repaint: _tick,
),
child: const SizedBox.expand(),
),
),
),
);
}
}
class _CurvePainter extends CustomPainter {
_CurvePainter({
required this.path,
required this.minWidth,
required this.maxWidth,
Listenable? repaint,
}) : super(repaint: repaint);
final Path path;
final double minWidth, maxWidth;
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect(
Offset.zero & size,
Paint()
..color = Colors.white
..style = PaintingStyle.fill,
);
canvas.drawPath(
path,
Paint()
..color = Colors.black
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(covariant _CurvePainter o) =>
o.path != path || o.minWidth != minWidth || o.maxWidth != maxWidth;
}
void _drawCurve(Path path, _Bezier c, double maxW) {
final dw = c.endWidth - c.startWidth;
int n = (c.length().floor() * 2);
if (n <= 0) n = 1;
for (var i = 0; i < n; i++) {
final t = i / n, mt = 1 - t;
final tt = t * t, ttt = tt * t;
final mm = mt * mt, mmm = mm * mt;
final x =
mmm * c.startPoint.x +
3 * mm * t * c.control1.x +
3 * mt * tt * c.control2.x +
ttt * c.endPoint.x;
final y =
mmm * c.startPoint.y +
3 * mm * t * c.control1.y +
3 * mt * tt * c.control2.y +
ttt * c.endPoint.y;
final w = min(c.startWidth + ttt * dw, maxW);
path.addArc(Rect.fromLTWH(x, y, w, w), 0, 180);
}
}
class _BezierBuilder {
_BezierBuilder({
required this.minWidth,
required this.maxWidth,
required this.velocityFilterWeight,
});
final double minWidth, maxWidth, velocityFilterWeight;
final _buf = <_TouchPoint>[];
double _lastV = 0, _lastW = 0;
void reset() {
_buf.clear();
_lastV = 0;
_lastW = (minWidth + maxWidth) / 2;
}
_Bezier? addPoint(_TouchPoint p) {
_buf.add(p);
if (_buf.length < 3) return null;
final w4 =
(_buf.length == 3)
? [_buf[0], _buf[0], _buf[1], _buf[2]]
: _buf.sublist(_buf.length - 4);
final a = w4[1], b = w4[2];
final dt = (b.time ?? 0) - (a.time ?? 0);
final v =
velocityFilterWeight * (dt == 0 ? 0 : _dist(a, b) / dt) +
(1 - velocityFilterWeight) * _lastV;
final w = max(maxWidth / (v + 1), minWidth);
final curve = _Bezier.fromPoints(points: w4, start: _lastW, end: w);
_lastV = v;
_lastW = w;
if (_buf.length > 3) _buf.removeAt(0);
return curve;
}
double _dist(_TouchPoint a, _TouchPoint b) {
final dx = b.x - a.x, dy = b.y - a.y;
return sqrt(dx * dx + dy * dy);
}
}
class _Bezier {
_Bezier(
this.startPoint,
this.control2,
this.control1,
this.endPoint,
this.startWidth,
this.endWidth,
);
final _TouchPoint startPoint, control2, control1, endPoint;
final double startWidth, endWidth;
static _Bezier fromPoints({
required List<_TouchPoint> points,
required double start,
required double end,
}) {
final c2 = _ctrl(points[0], points[1], points[2])[1];
final c3 = _ctrl(points[1], points[2], points[3])[0];
return _Bezier(points[1], c2, c3, points[2], start, end);
}
static List<_TouchPoint> _ctrl(
_TouchPoint s1,
_TouchPoint s2,
_TouchPoint s3,
) {
final m1 = Point((s1.x + s2.x) / 2.0, (s1.y + s2.y) / 2.0);
final m2 = Point((s2.x + s3.x) / 2.0, (s2.y + s3.y) / 2.0);
final l1 = sqrt(pow(s1.x - s2.x, 2) + pow(s1.y - s2.y, 2));
final l2 = sqrt(pow(s2.x - s3.x, 2) + pow(s2.y - s3.y, 2));
final k = (l1 + l2) == 0 ? 0.0 : l2 / (l1 + l2);
final cm = Point(m2.x + (m1.x - m2.x) * k, m2.y + (m1.y - m2.y) * k);
final tx = s2.x - cm.x, ty = s2.y - cm.y;
return [
_TouchPoint(x: m1.x + tx, y: m1.y + ty),
_TouchPoint(x: m2.x + tx, y: m2.y + ty),
];
}
double length() {
double len = 0, px = 0, py = 0;
for (int i = 0; i <= 10; i++) {
final t = i / 10;
final cx = _p(t, startPoint.x, control1.x, control2.x, endPoint.x);
final cy = _p(t, startPoint.y, control1.y, control2.y, endPoint.y);
if (i > 0) len += sqrt((cx - px) * (cx - px) + (cy - py) * (cy - py));
px = cx;
py = cy;
}
return (len.isNaN || len.isInfinite) ? 0 : len;
}
double _p(double t, double s, double c1, double c2, double e) {
final u = 1 - t, uu = u * u, tt = t * t;
return s * uu * u + 3 * c1 * uu * t + 3 * c2 * u * tt + e * tt * t;
}
}
class _TouchPoint {
const _TouchPoint({required this.x, required this.y, this.time});
final double x, y;
final int? time;
}
Steps to reproduce
In iOS, when I try to draw a continuous smooth curve, it unexpectedly connects the start and end points. I have tested on both Android and iOS the issue occurs, but when i disabled the Impeller in Android platform then the curves are rendering properly. This confirms the issue is specific to Impeller. We also verified with anti-aliasing both enabled and disabled, but the problem persists.
I have shared the code snippet to reproduce a issue and screen recording of issue in android and iOS platforms.
Expected results
The path must not connect the start and end points unless explicitly closed.
Actual results
On iOS devices, the stroke randomly connects the last point back to the first, producing an unwanted closing segment.
Code sample
Code sample
Screenshots or Video
Screenshots / Video demonstration
iOS:
issue.in.iOS.mov
Andoid:
issue.in.Android.mov
macOS:
Issue.in.macOS.mov
Web:
issue.in.web.mov
Logs
Logs
[Paste your logs here]Flutter Doctor output
Doctor output