I'm trying to recreate this roadmap-style UI:
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.
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;
}
}
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;
}
}