Skip to content

Unexpectedly start and end point get connected when drawing smooth curve paths on iOS platform #178022

Description

@Yuvaraj-Gajaraj

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
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;
}

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
[✓] Flutter (Channel stable, 3.35.4, on macOS 26.0.1 25A362 darwin-arm64, locale en-US) [914ms]
    • Flutter version 3.35.4 on channel stable at /Users/Documents/FlutterSDK/flutter2
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision d693b4b9db (7 weeks ago), 2025-09-16 14:27:41 +0000
    • Engine revision c298091351
    • Dart version 3.9.2
    • DevTools version 2.48.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios,
      cli-animations, enable-swift-package-manager, enable-lldb-debugging

[!] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,513ms]
    • Android SDK at /Users/Library/Android/sdk
    • Emulator version 36.2.12.0 (build_id 14214601) (CL:N/A)
    ✗ cmdline-tools component is missing.
      Try installing or updating Android Studio.
      Alternatively, download the tools from https://developer.android.com/studio#command-line-tools-only and make sure to set the
      ANDROID_HOME environment variable.
      See https://developer.android.com/studio/command-line for more details.
    ✗ Android license status unknown.
      Run `flutter doctor --android-licenses` to accept the SDK licenses.
      See https://flutter.dev/to/macos-android-setup for more details.

[✓] Xcode - develop for iOS and macOS (Xcode 26.0.1) [4.4s]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 17A400
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [13ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2025.2) [12ms]
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)

[✓] VS Code (version 1.105.1) [10ms]
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.122.0

[✓] Connected device (3 available) [9.0s]
    • sdk gphone64 arm64 (mobile) • emulator-5554 • android-arm64  • Android 16 (API 36) (emulator)
    • macOS (desktop)             • macos         • darwin-arm64   • macOS 26.0.1 25A362 darwin-arm64
    • Chrome (web)                • chrome        • web-javascript • Google Chrome 142.0.7444.60

[✓] Network resources [1,562ms]
    • All expected network resources are available.

! Doctor found issues in 1 category.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listc: parityWorks on one platform but not anothere: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.found in release: 3.35Found to occur in 3.35found in release: 3.38Found to occur in 3.38has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-androidAndroid applications specificallyplatform-iosiOS applications specificallyr: fixedIssue is closed as already fixed in a newer versionteam-engineOwned by Engine teamtriaged-engineTriaged by Engine teamwaiting for PR to land (fixed)A fix is in flight

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions