Search code examples
flutteruser-interfaceflutter-animationflutter-ui

How to build RoadMap UI using a dotted line


I'm trying to recreate this roadmap-style UI:

Example

I thought tentatively:

children: [

index 1: ListTile
index 2: dotted
index 3: ListTile
index 4: dotted
...

But in the picture, the intersecting line looks as if it goes under the circle and comes out.

I would be grateful if I could be helped to achieve the same result. Thanks.


Solution

  • I have created a road map widget based on this solution for the dash line.

    The road map widget consists of two main parts: the dashed line section and the section for drawing icons on the dashed line.

    Dashed curve line

    Path _customPath(Size size, double painterCornerRad, double iconSize, int iconCount) {
        final width = size.width;
        final path = Path();
        path.moveTo(iconSize / 2, iconSize / 2);
    
        for (int i = 1; i < iconCount; i++) {
          if (i % 2 == 0) {
            path
              ..relativeArcToPoint(
                Offset(-painterCornerRad, painterCornerRad),
                clockwise: true,
                radius: Radius.circular(painterCornerRad),
              )
              ..relativeLineTo(-width + (painterCornerRad * 2) + iconSize, 0)
              ..relativeArcToPoint(
                Offset(-painterCornerRad, painterCornerRad),
                clockwise: false,
                radius: Radius.circular(painterCornerRad),
              );
          } else {
            path
              ..relativeArcToPoint(
                Offset(painterCornerRad, painterCornerRad),
                clockwise: false,
                radius: Radius.circular(painterCornerRad),
              )
              ..relativeLineTo(width - (painterCornerRad * 2) - iconSize, 0)
              ..relativeArcToPoint(
                Offset(painterCornerRad, painterCornerRad),
                clockwise: true,
                radius: Radius.circular(painterCornerRad),
              );
          }
        }
    
        return path;
      }
    

    Icon on the dotted line

    I use a Stack to display the icon with calculated width and height to ensure that it fits the dash line path perfectly.

    CustomPaint(
          painter: DashedPathPainter(
            originalPath: (size) {
              return _customPath(size, pathCornerRad, iconSize, icons.length);
            },
            pathColor: Colors.black87,
            strokeWidth: 1.5,
            dashGapLength: 5.0,
            dashLength: 10.0,
          ),
          child: SizedBox(
              width: layoutSize,
              height: (pathCornerRad * 2) * (icons.length - 1) + iconSize,
              child: Stack(
                  children: icons
              )
          ),
        )
    

    Icon list wrapped by Positioned

    final icons = List.generate(20, (index) {
          final left = index == 0 || index % 2 == 0;
          return Positioned(
            left: left ? 0 : null,
            right: left ? null : 0,
            top: index * (pathCornerRad * 2),
            child: Container(
              width: iconSize,
              height: iconSize,
              decoration: const BoxDecoration(
                color: Colors.blue,
                shape: BoxShape.circle
              ),
              child: const Icon(Icons.question_mark_rounded, color: Colors.white)
            ),
          );
        });
    

    You can set the value to your preference or make it the parameter of the widget.

    const iconSize = 40.0; // Size of the icon
    const pathCornerRad = 44.0; // Corner radius of the curved line
    const layoutSize = 350.0; // TThe width of the path can stretch to.
    

    Below is the complete source code that you can easily copy and paste to run on dartpad.dev.

    import 'dart:ui' as ui;
    import 'dart:math' as math;
    
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: ExampleRoadMap(),
          ),
        );
      }
    }
    
    class ExampleRoadMap extends StatefulWidget {
      const ExampleRoadMap({super.key});
    
      @override
      State<ExampleRoadMap> createState() => _ExampleRoadMapState();
    }
    
    class _ExampleRoadMapState extends State<ExampleRoadMap> {
      @override
      Widget build(BuildContext context) {
        const iconSize = 40.0;
        const pathCornerRad = 44.0;
        const layoutSize = 350.0;
        final icons = List.generate(20, (index) {
          final left = index == 0 || index % 2 == 0;
          return Positioned(
            left: left ? 0 : null,
            right: left ? null : 0,
            top: index * (pathCornerRad * 2),
            child: Container(
                width: iconSize,
                height: iconSize,
                decoration:
                    const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
                child:
                    const Icon(Icons.question_mark_rounded, color: Colors.white)),
          );
        });
        return Center(
          child: SingleChildScrollView(
            child: Column(
              children: [
                CustomPaint(
                  painter: DashedPathPainter(
                    originalPath: (size) {
                      return _customPath(
                          size, pathCornerRad, iconSize, icons.length);
                    },
                    pathColor: Colors.black87,
                    strokeWidth: 1.5,
                    dashGapLength: 5.0,
                    dashLength: 10.0,
                  ),
                  child: SizedBox(
                      width: layoutSize,
                      height: (pathCornerRad * 2) * (icons.length - 1) + iconSize,
                      child: Stack(children: icons)),
                ),
              ],
            ),
          ),
        );
      }
    
      Path _customPath(Size size, double painterCornerRad, double iconSize,
          int destinationIcon) {
        final width = size.width;
        final path = Path();
        path.moveTo(iconSize / 2, iconSize / 2);
    
        for (int i = 1; i < destinationIcon; i++) {
          if (i % 2 == 0) {
            path
              ..relativeArcToPoint(
                Offset(-painterCornerRad, painterCornerRad),
                clockwise: true,
                radius: Radius.circular(painterCornerRad),
              )
              ..relativeLineTo(-width + (painterCornerRad * 2) + iconSize, 0)
              ..relativeArcToPoint(
                Offset(-painterCornerRad, painterCornerRad),
                clockwise: false,
                radius: Radius.circular(painterCornerRad),
              );
          } else {
            path
              ..relativeArcToPoint(
                Offset(painterCornerRad, painterCornerRad),
                clockwise: false,
                radius: Radius.circular(painterCornerRad),
              )
              ..relativeLineTo(width - (painterCornerRad * 2) - iconSize, 0)
              ..relativeArcToPoint(
                Offset(painterCornerRad, painterCornerRad),
                clockwise: true,
                radius: Radius.circular(painterCornerRad),
              );
          }
        }
    
        return path;
      }
    }
    
    class DashedPathPainter extends CustomPainter {
      final Path Function(Size) originalPath;
      final Color pathColor;
      final double strokeWidth;
      final double dashGapLength;
      final double dashLength;
      late DashedPathProperties _dashedPathProperties;
    
      DashedPathPainter({
        required this.originalPath,
        required this.pathColor,
        this.strokeWidth = 3.0,
        this.dashGapLength = 5.0,
        this.dashLength = 10.0,
      });
    
      @override
      void paint(Canvas canvas, Size size) {
        _dashedPathProperties = DashedPathProperties(
          path: Path(),
          dashLength: dashLength,
          dashGapLength: dashGapLength,
        );
        final dashedPath =
            _getDashedPath(originalPath.call(size), dashLength, dashGapLength);
        canvas.drawPath(
          dashedPath,
          Paint()
            ..style = PaintingStyle.stroke
            ..color = pathColor
            ..strokeWidth = strokeWidth,
        );
      }
    
      @override
      bool shouldRepaint(DashedPathPainter oldDelegate) =>
          oldDelegate.originalPath != originalPath ||
          oldDelegate.pathColor != pathColor ||
          oldDelegate.strokeWidth != strokeWidth ||
          oldDelegate.dashGapLength != dashGapLength ||
          oldDelegate.dashLength != dashLength;
    
      Path _getDashedPath(
        Path originalPath,
        double dashLength,
        double dashGapLength,
      ) {
        final metricsIterator = originalPath.computeMetrics().iterator;
        while (metricsIterator.moveNext()) {
          final metric = metricsIterator.current;
          _dashedPathProperties.extractedPathLength = 0.0;
          while (_dashedPathProperties.extractedPathLength < metric.length) {
            if (_dashedPathProperties.addDashNext) {
              _dashedPathProperties.addDash(metric, dashLength);
            } else {
              _dashedPathProperties.addDashGap(metric, dashGapLength);
            }
          }
        }
        return _dashedPathProperties.path;
      }
    }
    
    class DashedPathProperties {
      double extractedPathLength;
      Path path;
    
      final double _dashLength;
      double _remainingDashLength;
      double _remainingDashGapLength;
      bool _previousWasDash;
    
      DashedPathProperties({
        required this.path,
        required double dashLength,
        required double dashGapLength,
      })  : assert(dashLength > 0.0, 'dashLength must be > 0.0'),
            assert(dashGapLength > 0.0, 'dashGapLength must be > 0.0'),
            _dashLength = dashLength,
            _remainingDashLength = dashLength,
            _remainingDashGapLength = dashGapLength,
            _previousWasDash = false,
            extractedPathLength = 0.0;
    
      bool get addDashNext {
        if (!_previousWasDash || _remainingDashLength != _dashLength) {
          return true;
        }
        return false;
      }
    
      void addDash(ui.PathMetric metric, double dashLength) {
        // Calculate lengths (actual + available)
        final end = _calculateLength(metric, _remainingDashLength);
        final availableEnd = _calculateLength(metric, dashLength);
        // Add path
        final pathSegment = metric.extractPath(extractedPathLength, end);
        path.addPath(pathSegment, Offset.zero);
        // Update
        final delta = _remainingDashLength - (end - extractedPathLength);
        _remainingDashLength = _updateRemainingLength(
          delta: delta,
          end: end,
          availableEnd: availableEnd,
          initialLength: dashLength,
        );
        extractedPathLength = end;
        _previousWasDash = true;
      }
    
      void addDashGap(ui.PathMetric metric, double dashGapLength) {
        // Calculate lengths (actual + available)
        final end = _calculateLength(metric, _remainingDashGapLength);
        final availableEnd = _calculateLength(metric, dashGapLength);
        // Move path's end point
        ui.Tangent tangent = metric.getTangentForOffset(end)!;
        path.moveTo(tangent.position.dx, tangent.position.dy);
        // Update
        final delta = end - extractedPathLength;
        _remainingDashGapLength = _updateRemainingLength(
          delta: delta,
          end: end,
          availableEnd: availableEnd,
          initialLength: dashGapLength,
        );
        extractedPathLength = end;
        _previousWasDash = false;
      }
    
      double _calculateLength(ui.PathMetric metric, double addedLength) {
        return math.min(extractedPathLength + addedLength, metric.length);
      }
    
      double _updateRemainingLength({
        required double delta,
        required double end,
        required double availableEnd,
        required double initialLength,
      }) {
        return (delta > 0 && availableEnd == end) ? delta : initialLength;
      }
    }
    

    enter image description here

    Updated Version

    There is a different version where you can set a specific color for each path. If no colors are set for the path index, it will take the pathColor as default.

        import 'dart:ui' as ui;
        import 'dart:math' as math;
        
        import 'package:flutter/material.dart';
        void main() {
          runApp(const MyApp());
        }
        
        class MyApp extends StatelessWidget {
          const MyApp({super.key});
        
          @override
          Widget build(BuildContext context) {
            return const MaterialApp(
              debugShowCheckedModeBanner: false,
              home: Scaffold(
                body: Center(
                  child: ExampleRoadMap(),
                ),
              ),
            );
          }
        }
        
        class ExampleRoadMap extends StatefulWidget {
          const ExampleRoadMap({super.key});
        
          @override
          State<ExampleRoadMap> createState() => _ExampleRoadMapState();
        }
        
        class _ExampleRoadMapState extends State<ExampleRoadMap> {
          @override
          Widget build(BuildContext context) {
            const iconSize = 60.0;
            const pathCornerRad = 50.0;
            const layoutSize = 350.0;
            final icons = List.generate(15, (index) {
              final left = index == 0 || index % 2 == 0;
              return Positioned(
                left: left ? 0 : null,
                right: left ? null : 0,
                top: index * (pathCornerRad * 2),
                child: Container(
                    width: iconSize,
                    height: iconSize,
                    decoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
                    child: const Icon(Icons.question_mark_rounded, color: Colors.white)),
              );
            });
            return Center(
              child: SingleChildScrollView(
                child: Column(
                  children: [
                    CustomPaint(
                      painter: DashedPathPainter(
                        originalPath: (size) {
                          return _customPath(size, pathCornerRad, iconSize, icons.length);
                        },
                        pathColors: [
                          Colors.orange,
                          Colors.blue,
                          Colors.green,
                          Colors.grey,
                          Colors.indigo,
                          Colors.orangeAccent,
                          Colors.red,
                          Colors.amberAccent,
                          Colors.pink
                        ],
                        pathColor: Colors.black87,
                        strokeWidth: 3,
                        dashGapLength: 5.0,
                        dashLength: 10.0,
                      ),
                      child: SizedBox(
                          width: layoutSize,
                          height: (pathCornerRad * 2) * (icons.length - 1) + iconSize,
                          child: Stack(children: icons)),
                    ),
                  ],
                ),
              ),
            );
          }
        
          List<Path> _customPath(Size size, double painterCornerRad, double iconSize, int iconCount) {
            final width = size.width;
        
            List<Path> paths = [];
        
            for (int i = 0; i < iconCount - 1; i++) {
              final path = Path();
        
              if (i % 2 == 0) {
                final startX = iconSize / 2;
                final startY = (i * painterCornerRad * 2) + (iconSize / 2);
                path.moveTo(startX, startY);
                path
                  ..arcToPoint(
                    Offset(startX + painterCornerRad, startY + painterCornerRad),
                    clockwise: false,
                    radius: Radius.circular(painterCornerRad),
                  )
                  ..relativeLineTo(width - (painterCornerRad * 2) - iconSize, 0)
                  ..relativeArcToPoint(
                    Offset(painterCornerRad, painterCornerRad),
                    clockwise: true,
                    radius: Radius.circular(painterCornerRad),
                  );
              } else {
                final startX = width - iconSize / 2;
                final startY = (i * painterCornerRad * 2) + (iconSize / 2);
                path.moveTo(startX, startY);
                path
                  ..arcToPoint(
                    Offset(-painterCornerRad + startX, startY + painterCornerRad),
                    clockwise: true,
                    radius: Radius.circular(painterCornerRad),
                  )
                  ..relativeLineTo(-width + (painterCornerRad * 2) + iconSize, 0)
                  ..relativeArcToPoint(
                    Offset(-painterCornerRad, painterCornerRad),
                    clockwise: false,
                    radius: Radius.circular(painterCornerRad),
                  );
              }
        
              paths.add(path);
            }
        
            return paths;
          }
        }
        
        class DashedPathPainter extends CustomPainter {
          final List<Path> Function(Size) originalPath;
          final List<Color> pathColors;
          final Color pathColor;
          final double strokeWidth;
          final double dashGapLength;
          final double dashLength;
        
          DashedPathPainter({
            required this.originalPath,
            required this.pathColors,
            required this.pathColor,
            this.strokeWidth = 3.0,
            this.dashGapLength = 5.0,
            this.dashLength = 10.0,
          });
        
          @override
          void paint(Canvas canvas, Size size) {
            final paths = originalPath.call(size);
        
            for (int i = 0; i < paths.length; i++) {
              final dashedPath = _getDashedPath(
                  DashedPathProperties(
                    path: Path(),
                    dashLength: dashLength,
                    dashGapLength: dashGapLength,
                  ),
                  paths[i],
                  dashLength,
                  dashGapLength);
        
              Color color = pathColor;
              if(i < pathColors.length) {
                color = pathColors[i];
              }
        
              final paint = Paint()
                ..style = PaintingStyle.stroke
                ..color = color
                ..strokeWidth = strokeWidth;
              canvas.drawPath(dashedPath, paint);
            }
          }
        
          @override
          bool shouldRepaint(DashedPathPainter oldDelegate) =>
              oldDelegate.originalPath != originalPath ||
              oldDelegate.pathColor != pathColor ||
              oldDelegate.pathColors != pathColors ||
              oldDelegate.strokeWidth != strokeWidth ||
              oldDelegate.dashGapLength != dashGapLength ||
              oldDelegate.dashLength != dashLength;
        
          Path _getDashedPath(
            DashedPathProperties pathProps,
            Path originalPath,
            double dashLength,
            double dashGapLength,
          ) {
            final metricsIterator = originalPath.computeMetrics().iterator;
            while (metricsIterator.moveNext()) {
              final metric = metricsIterator.current;
              pathProps.extractedPathLength = 0.0;
              while (pathProps.extractedPathLength < metric.length) {
                if (pathProps.addDashNext) {
                  pathProps.addDash(metric, dashLength);
                } else {
                  pathProps.addDashGap(metric, dashGapLength);
                }
              }
            }
            return pathProps.path;
          }
        }
        
        class DashedPathProperties {
          double extractedPathLength;
          Path path;
        
          final double _dashLength;
          double _remainingDashLength;
          double _remainingDashGapLength;
          bool _previousWasDash;
        
          DashedPathProperties({
            required this.path,
            required double dashLength,
            required double dashGapLength,
          })  : assert(dashLength > 0.0, 'dashLength must be > 0.0'),
                assert(dashGapLength > 0.0, 'dashGapLength must be > 0.0'),
                _dashLength = dashLength,
                _remainingDashLength = dashLength,
                _remainingDashGapLength = dashGapLength,
                _previousWasDash = false,
                extractedPathLength = 0.0;
        
          bool get addDashNext {
            if (!_previousWasDash || _remainingDashLength != _dashLength) {
              return true;
            }
            return false;
          }
        
          void addDash(ui.PathMetric metric, double dashLength) {
            // Calculate lengths (actual + available)
            final end = _calculateLength(metric, _remainingDashLength);
            final availableEnd = _calculateLength(metric, dashLength);
            // Add path
            final pathSegment = metric.extractPath(extractedPathLength, end);
            path.addPath(pathSegment, Offset.zero);
            // Update
            final delta = _remainingDashLength - (end - extractedPathLength);
            _remainingDashLength = _updateRemainingLength(
              delta: delta,
              end: end,
              availableEnd: availableEnd,
              initialLength: dashLength,
            );
            extractedPathLength = end;
            _previousWasDash = true;
          }
        
          void addDashGap(ui.PathMetric metric, double dashGapLength) {
            // Calculate lengths (actual + available)
            final end = _calculateLength(metric, _remainingDashGapLength);
            final availableEnd = _calculateLength(metric, dashGapLength);
            // Move path's end point
            ui.Tangent tangent = metric.getTangentForOffset(end)!;
            path.moveTo(tangent.position.dx, tangent.position.dy);
            // Update
            final delta = end - extractedPathLength;
            _remainingDashGapLength = _updateRemainingLength(
              delta: delta,
              end: end,
              availableEnd: availableEnd,
              initialLength: dashGapLength,
            );
            extractedPathLength = end;
            _previousWasDash = false;
          }
        
          double _calculateLength(ui.PathMetric metric, double addedLength) {
            return math.min(extractedPathLength + addedLength, metric.length);
          }
        
          double _updateRemainingLength({
            required double delta,
            required double end,
            required double availableEnd,
            required double initialLength,
          }) {
            return (delta > 0 && availableEnd == end) ? delta : initialLength;
          }
        }
    

    enter image description here