Как создать пользовательский интерфейс RoadMap, используя пунктирную линию

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

Как создать пользовательский интерфейс RoadMap, используя пунктирную линию

Я осторожно подумал:

children: [

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

Но на картинке пересекающаяся линия выглядит так, будто заходит под круг и выходит наружу.

Буду признателен, если мне помогут достичь такого же результата. Спасибо.

Добро пожаловать в СО. Вы пробовали какой-нибудь код. Без некоторых усилий будет сложно предоставить полный написанный код.

gretal 25.03.2024 03:45
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
1
134
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Я создал виджет дорожной карты на основе этого решения для пунктирной линии.

Виджет карты дорог состоит из двух основных частей: секции пунктирной линии и секции рисования значков на пунктирной линии.

Пунктирная кривая линия

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

Moha Med 25.03.2024 09:46

Чтобы указать цвет линии от точки к точке, вам необходимо разделить путь на несколько сегментов, а затем нарисовать каждый из них на холсте. Если вы предпочитаете, я могу изменить пример кода, чтобы разрешить установку цвета для каждой строки.

Tung Ha 25.03.2024 10:43

Я надеюсь на это, в любое время, когда захотите, и я буду очень благодарен вам за ваши усилия. Кроме того, у меня есть новый вопрос, и в настоящее время я не могу протестировать код. Если вы измените положение виджета посередине, оно может быть в начале или в конце, или даже в середине списка. Что необходимо изменить, чтобы линия стала вертикальной, а не полукруглой?

Moha Med 25.03.2024 11:41

@MohaMed Я обновил ответ на заданный цвет пути. Что касается упомянутого вами «Виджета», вы имеете в виду виджет вопросительного знака в моем примере? Если да, то вам придется поиграть с _customPath и Stack, чтобы это сделать. _customPath — здесь определяется путь. Stack — здесь в пользовательском интерфейсе отображается «Виджет вопросительного знака», соответствующий пути.

Tung Ha 25.03.2024 12:10

Мой пример выше представляет собой фиксированный дизайн для этого типа дорожной карты. Это не так просто, как исправить всего 1 или 2 параметра, и его можно адаптировать и для других проектов. С CustomPainter есть множество возможностей. Я призываю вас обратиться к моему примеру и достичь желаемого результата. Удачи!

Tung Ha 25.03.2024 12:20

Вы помогли мне реализовать идею и получить результат. Спасибо большое за ваши усилия и время. @ТунгХа

Moha Med 28.03.2024 08:27

Другие вопросы по теме