Skip to content

Flutter 3.38 has issues rendering paths in canvas #178547

@CodeDoctorDE

Description

@CodeDoctorDE

Steps to reproduce

Call toImage on picture recorder or RenderPaintBoundaries containing an canvas where it has a background and rendered paths on it.

Tested on Linux Fedora 43 with flutter 3.38.1, on web this problem doesn't exist. Last stable 3.35.7 didn't have the issue.

The problem is really visible in my note taking app https://github.com/LinwoodDev/Butterfly

Expected results

Rendered output like in the app. Background yellow and above the paths.

Actual results

The background behind the strokes won't be rendered

Code sample

Code sample

https://gist.github.com/CodeDoctorDE/0132efc42499331637776db6b6a96da8

import 'dart:math';
import 'dart:ui' as ui;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Rainbow Paths',
      theme: ThemeData(
        // Using a modern Material 3 theme.
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const RainbowPathsPage(),
    );
  }
}

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

  @override
  State<RainbowPathsPage> createState() => _RainbowPathsPageState();
}

class _RainbowPathsPageState extends State<RainbowPathsPage> {
  List<List<Offset>> _pathsData = [];
  final GlobalKey _repaintBoundaryKey = GlobalKey();

  // Configuration for path generation:
  final int _numberOfPaths = 100; // How many distinct paths to draw.
  final int _segmentsPerPath =
      15; // How many line segments each path will have.
  // Maximum length a segment can extend from the previous point, to keep paths somewhat continuous.
  final double _maxSegmentLength = 100.0;

  @override
  void initState() {
    super.initState();
    // Schedule the path generation after the first frame has been rendered.
    // This ensures that `context` is fully initialized and has valid layout information
    // (like the screen size) when `_generatePathsForSize` is called.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        _generate(MediaQuery.of(context).size, 0);
      }
    });
  }

  void _generate(Size size, [int? seed]) {
    setState(() {
      _pathsData = _createPaths(size, seed);
    });
  }

  List<List<Offset>> _createPaths(Size size, [int? seed]) {
    final Random random = Random(seed);
    final List<List<Offset>> newPaths = [];
    for (int i = 0; i < _numberOfPaths; i++) {
      final List<Offset> pathPoints = [];
      // Start each path from a random point within the canvas
      Offset currentPoint = Offset(
        random.nextDouble() * size.width,
        random.nextDouble() * size.height,
      );
      pathPoints.add(currentPoint);

      for (int j = 0; j < _segmentsPerPath; j++) {
        // Generate a random delta (dx, dy) for the next point.
        // `_random.nextDouble() * 2 - 1` generates a value between -1 and 1.
        final double dx = (random.nextDouble() * 2 - 1) * _maxSegmentLength;
        final double dy = (random.nextDouble() * 2 - 1) * _maxSegmentLength;

        // Calculate the potential next point
        double nextX = currentPoint.dx + dx;
        double nextY = currentPoint.dy + dy;

        // Clamp the next point's coordinates to ensure it stays within the canvas bounds
        nextX = nextX.clamp(0.0, size.width);
        nextY = nextY.clamp(0.0, size.height);

        currentPoint = Offset(nextX, nextY);
        pathPoints.add(currentPoint);
      }
      newPaths.add(pathPoints);
    }
    return newPaths;
  }

  /// Determines the color for a path based on its index.
  /// It creates a color gradient that transitions from red (for the first path, index 0)
  /// through blue (around the middle paths) to black (for the last path).
  Color _getPathColor(int index) {
    if (_numberOfPaths <= 1) {
      // Handle cases with 0 or 1 path to avoid division by zero or incorrect gradient.
      return index == 0 ? Colors.red : Colors.black;
    }

    // Normalize the index to a value 't' between 0.0 and 1.0,
    // representing the path's position in the sequence.
    double t = index / (_numberOfPaths - 1);

    // Interpolate colors: Red -> Blue for the first half, then Blue -> Black for the second half.
    if (t < 0.5) {
      // For paths in the first half (0.0 <= t < 0.5), interpolate from Red to Blue.
      // `t * 2` scales 't' from [0.0, 0.5) to [0.0, 1.0) for the `Color.lerp` function.
      return Color.lerp(Colors.red, Colors.blue, t * 2)!;
    } else {
      // For paths in the second half (0.5 <= t <= 1.0), interpolate from Blue to Black.
      // `(t - 0.5) * 2` scales 't' from [0.5, 1.0] to [0.0, 1.0] for the `Color.lerp` function.
      return Color.lerp(Colors.blue, Colors.black, (t - 0.5) * 2)!;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Rainbow Random Paths'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: GestureDetector(
        child: RepaintBoundary(
          key: _repaintBoundaryKey,
          child: CustomPaint(
            // Ensure CustomPaint takes up all available space within its parent.
            size: Size.infinite,
            // Only provide a painter if `_pathsData` is not empty.
            // This prevents `CustomPaint` from attempting to draw before data is ready.
            painter: _pathsData.isEmpty
                ? null
                : RainbowPathsPainter(
                    pathsData: _pathsData,
                    getPathColor: _getPathColor,
                  ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        heroTag: 'downloadFab',
        onPressed: _saveCurrentView,
        tooltip: 'Download Snapshot',
        child: const Icon(Icons.download),
      ),
    );
  }

  Future<void> _saveCurrentView() async {
    if (kIsWeb) {
      _showSnackBar('Download not supported on web.');
      return;
    }
    final BuildContext? boundaryContext = _repaintBoundaryKey.currentContext;
    final RenderObject? renderObject = boundaryContext?.findRenderObject();
    if (renderObject is! RenderRepaintBoundary) {
      _showSnackBar('Nothing to save right now.');
      return;
    }
    final RenderRepaintBoundary boundary = renderObject;

    try {
      final double pixelRatio = MediaQuery.of(context).devicePixelRatio;
      final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
      final ByteData? byteData = await image.toByteData(
        format: ui.ImageByteFormat.png,
      );
      image.dispose();
      if (byteData == null) {
        _showSnackBar('Failed to encode image.');
        return;
      }

      final Directory dir = Directory.current;
      final String fileName =
          'randomsequence_${DateTime.now().millisecondsSinceEpoch}.png';
      final File file = File(_joinPath(dir.path, fileName));
      await file.writeAsBytes(byteData.buffer.asUint8List());
      _showSnackBar('Saved to ${file.path}');
    } catch (error) {
      _showSnackBar('Save failed: $error');
    }
  }

  String _joinPath(String base, String segment) {
    if (base.endsWith(Platform.pathSeparator)) {
      return '$base$segment';
    }
    return '$base${Platform.pathSeparator}$segment';
  }

  void _showSnackBar(String message) {
    if (!mounted) {
      return;
    }
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(SnackBar(content: Text(message)));
  }
}

/// A custom painter responsible for drawing the generated paths onto the canvas.
class RainbowPathsPainter extends CustomPainter {
  final List<List<Offset>> pathsData;
  final Function(int index) getPathColor;

  RainbowPathsPainter({required this.pathsData, required this.getPathColor});

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(
      Rect.fromLTWH(0, 0, size.width, size.height),
      Paint()..color = Colors.yellow,
    );
    // Iterate through each path in the `pathsData` list.
    for (int i = 0; i < pathsData.length; i++) {
      final List<Offset> pathPoints = pathsData[i];
      if (pathPoints.length < 2) {
        continue;
      }

      final Paint paint = Paint()
        ..color = getPathColor(i)
        ..strokeWidth = 3.0 + (i * 0.05)
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..isAntiAlias = true;

      final path = Path()..moveTo(pathPoints.first.dx, pathPoints.first.dy);

      for (int j = 1; j < pathPoints.length; j++) {
        final Offset point = pathPoints[j];
        path.lineTo(point.dx, point.dy);
      }

      // 3. Draw the path to the canvas
      canvas.drawPath(path, paint);
    }
  }

  @override
  bool shouldRepaint(covariant RainbowPathsPainter oldDelegate) {
    // This method determines if the painter needs to redraw.
    // We want to repaint if the `pathsData` (the list of all paths) has changed.
    // Since `_pathsData` is reassigned to a new list instance every time paths are regenerated,
    // this comparison (`!=`) correctly triggers a repaint.
    return oldDelegate.pathsData != pathsData;
  }
}

Screenshots or Video

Screenshots / Video demonstration

Expected:

Image

Actual:

Image

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.38.1, on Fedora Linux 43 (KDE Plasma Desktop Edition) 6.17.7-300.fc43.x86_64, locale de_DE.UTF-8) [36ms]
    • Flutter version 3.38.1 on channel stable at /home/codedoctor/Tools/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision b45fa18946 (2 days ago), 2025-11-12 22:09:06 -0600
    • Engine revision b5990e5ccc
    • Dart version 3.10.0
    • DevTools version 2.51.1
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging

[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0-rc1) [1.259ms]
    • Android SDK at /home/codedoctor/Android/Sdk
    • Emulator version 36.2.12.0 (build_id 14214601) (CL:N/A)
    • Platform android-36, build-tools 36.1.0-rc1
    • Java binary at: /home/codedoctor/.local/share/JetBrains/Toolbox/apps/android-studio/jbr/bin/java
      This is the JDK bundled with the latest Android Studio installation on this machine.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
    • All Android licenses accepted.

[✓] Chrome - develop for the web [5ms]
    • CHROME_EXECUTABLE = /usr/bin/chromium-browser

[✓] Linux toolchain - develop for Linux desktop [405ms]
    • clang version 21.1.4 (Fedora 21.1.4-1.fc43)
    • cmake version 3.31.6
    • ninja version 1.13.1
    • pkg-config version 2.3.0
    • OpenGL core renderer: NVIDIA GeForce RTX 4070 Ti SUPER/PCIe/SSE2
    • OpenGL core version: 4.6.0 NVIDIA 580.105.08
    • OpenGL core shading language version: 4.60 NVIDIA
    • OpenGL ES renderer: NVIDIA GeForce RTX 4070 Ti SUPER/PCIe/SSE2
    • OpenGL ES version: OpenGL ES 3.2 NVIDIA 580.105.08
    • OpenGL ES shading language version: OpenGL ES GLSL ES 3.20
    • GL_EXT_framebuffer_blit: yes
    • GL_EXT_texture_format_BGRA8888: yes

[✓] Connected device (2 available) [133ms]
    • Linux (desktop) • linux  • linux-x64      • Fedora Linux 43 (KDE Plasma Desktop Edition) 6.17.7-300.fc43.x86_64
    • Chrome (web)    • chrome • web-javascript • Chromium 142.0.7444.134 Fedora Project

[✓] Network resources [541ms]
    • All expected network resources are available.

• No issues found!

Metadata

Metadata

Assignees

No one assigned

    Labels

    r: fixedIssue is closed as already fixed in a newer version

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions