Я пытаюсь воссоздать этот пользовательский интерфейс в стиле дорожной карты:

Я осторожно подумал:
children: [
index 1: ListTile
index 2: dotted
index 3: ListTile
index 4: dotted
...
Но на картинке пересекающаяся линия выглядит так, будто заходит под круг и выходит наружу.
Буду признателен, если мне помогут достичь такого же результата. Спасибо.





Я создал виджет дорожной карты на основе этого решения для пунктирной линии.
Виджет карты дорог состоит из двух основных частей: секции пунктирной линии и секции рисования значков на пунктирной линии.
Пунктирная кривая линия
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;
}
Значок на пунктирной линии
Я использую Stack для отображения значка с рассчитанной шириной и высотой, чтобы гарантировать, что он идеально соответствует пути пунктирной линии.
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
)
),
)
Список значков, завернутый в 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)
),
);
});
Вы можете установить значение по своему усмотрению или сделать его параметром виджета.
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.
Ниже приведен полный исходный код, который вы можете легко скопировать и вставить для запуска на 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;
}
}
Обновленная версия
Существует другая версия, в которой вы можете установить определенный цвет для каждого пути. Если для индекса пути не заданы цвета, по умолчанию будет использоваться pathColor.
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;
}
}
Как указать цвет линии от точки к точке. Для меня ответ вполне приемлемый, тем более, что мне предстоит покопаться в CustomPainter, огромное спасибо @Tung
Чтобы указать цвет линии от точки к точке, вам необходимо разделить путь на несколько сегментов, а затем нарисовать каждый из них на холсте. Если вы предпочитаете, я могу изменить пример кода, чтобы разрешить установку цвета для каждой строки.
Я надеюсь на это, в любое время, когда захотите, и я буду очень благодарен вам за ваши усилия. Кроме того, у меня есть новый вопрос, и в настоящее время я не могу протестировать код. Если вы измените положение виджета посередине, оно может быть в начале или в конце, или даже в середине списка. Что необходимо изменить, чтобы линия стала вертикальной, а не полукруглой?
@MohaMed Я обновил ответ на заданный цвет пути. Что касается упомянутого вами «Виджета», вы имеете в виду виджет вопросительного знака в моем примере? Если да, то вам придется поиграть с _customPath и Stack, чтобы это сделать. _customPath — здесь определяется путь. Stack — здесь в пользовательском интерфейсе отображается «Виджет вопросительного знака», соответствующий пути.
Мой пример выше представляет собой фиксированный дизайн для этого типа дорожной карты. Это не так просто, как исправить всего 1 или 2 параметра, и его можно адаптировать и для других проектов. С CustomPainter есть множество возможностей. Я призываю вас обратиться к моему примеру и достичь желаемого результата. Удачи!
Вы помогли мне реализовать идею и получить результат. Спасибо большое за ваши усилия и время. @ТунгХа
Добро пожаловать в СО. Вы пробовали какой-нибудь код. Без некоторых усилий будет сложно предоставить полный написанный код.