Skip to content

About the implementation method of Arrow PolyLine #2180

@ZTMIDGO

Description

@ZTMIDGO

What do you want implemented?

In the current version 8.2.2, there is no Arrow PolyLine, so I took some time to implement this feature.

What other alternatives are available?

The following code implements Arrow PolyLine with minor modifications based on version 8.2.2.
You only need to modify a small amount of code based on the Dashed property of _PolylinePainter to implement it.
This is Core code

if (isDashed) {
          final DashedPixelHiker hiker = DashedPixelHiker(
            offsets: offsets,
            segmentValues: polyline.pattern.segments!,
            patternFit: polyline.pattern.patternFit!,
            closePath: false,
            canvasSize: size,
            strokeWidth: largestStrokeWidth,
          );


          for (final visibleSegment in hiker.getAllVisibleSegments()) {
            final dx = visibleSegment.end.dx - visibleSegment.begin.dx;
            final dy = visibleSegment.end.dy - visibleSegment.begin.dy;
            var angle = atan2(dy, dx);

            for (final path in paths) {
              buildTriangle(visibleSegment.begin, strokeWidth, angle, path);
            }
          }
        }

 void buildTriangle(Offset center, double size, double angleRad, ui.Path path) {
    final r = size;
    final alpha = pi / 6; // 30度展开角
    final baseR = r * 0.6;

    final p0 = Offset(center.dx + r * cos(angleRad),
        center.dy + r * sin(angleRad));
    final p1 = Offset(center.dx + baseR * cos(angleRad + alpha),
        center.dy + baseR * sin(angleRad + alpha));
    final p2 = Offset(center.dx + baseR * cos(angleRad - alpha),
        center.dy + baseR * sin(angleRad - alpha));

    path
      ..moveTo(p0.dx, p0.dy)
      ..lineTo(p1.dx, p1.dy)
      ..lineTo(p2.dx, p2.dy)
      ..close();
  }
Simulator.Screen.Recording.-.iPhone.16e.-.2026-01-16.at.11.28.46.mov

Complete demo code

import 'dart:core';
import 'dart:math' as math;
import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/src/layer/shared/feature_layer_utils.dart';
import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart';
import 'package:flutter_map/src/layer/shared/layer_projection_simplification/state.dart';
import 'package:flutter_map/src/layer/shared/layer_projection_simplification/widget.dart';
import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart';
import 'package:flutter_map/src/misc/extensions.dart';
import 'package:flutter_map/src/misc/offsets.dart';
import 'package:flutter_map/src/misc/simplify.dart';
import 'package:latlong2/latlong.dart';

/// A [Polyline] (aka. LineString) layer for [FlutterMap].
@immutable
base class PolylineLayer<R extends Object>
    extends ProjectionSimplificationManagementSupportedWidget {
  /// [Polyline]s to draw
  final List<Polyline<R>> polylines;

  /// Acceptable extent outside of viewport before culling polyline segments
  ///
  /// May need to be increased if the [Polyline.strokeWidth] +
  /// [Polyline.borderStrokeWidth] is large. See the
  /// [online documentation](https://docs.fleaflet.dev/layers/polyline-layer#culling)
  /// for more info.
  ///
  /// Defaults to 10. Set to `null` to disable culling.
  final double? cullingMargin;

  /// {@macro fm.lhn.layerHitNotifier.usage}
  final LayerHitNotifier<R>? hitNotifier;

  /// The minimum radius of the hittable area around each [Polyline] in logical
  /// pixels
  ///
  /// The entire visible area is always hittable, but if the visible area is
  /// smaller than this, then this will be the hittable area.
  ///
  /// Defaults to 10.
  final double minimumHitbox;

  /// Whether polylines should only be drawn/projected onto a single world
  /// instead of potentially being drawn onto adjacent worlds (based on the
  /// shortest distance)
  ///
  /// When set `true` with a CRS which does support
  /// [Crs.replicatesWorldLongitude], polylines will still be repeated across
  /// worlds, but each polyline will only be drawn within one world.
  ///
  /// Defaults to `false`.
  final bool drawInSingleWorld;

  /// Create a new [PolylineLayer] to use as child inside [FlutterMap.children].
  const PolylineLayer({
    super.key,
    required this.polylines,
    this.cullingMargin = 10,
    this.hitNotifier,
    this.minimumHitbox = 10,
    this.drawInSingleWorld = false,
    super.simplificationTolerance,
  }) : super();

  @override
  State<PolylineLayer<R>> createState() => _PolylineLayerState<R>();
}

class _PolylineLayerState<R extends Object> extends State<PolylineLayer<R>>
    with
        ProjectionSimplificationManagement<_ProjectedPolyline<R>, Polyline<R>,
            PolylineLayer<R>> {
  @override
  _ProjectedPolyline<R> projectElement({
    required Projection projection,
    required Polyline<R> element,
  }) =>
      _ProjectedPolyline._fromPolyline(
        projection,
        element,
        widget.drawInSingleWorld,
      );

  @override
  _ProjectedPolyline<R> simplifyProjectedElement({
    required _ProjectedPolyline<R> projectedElement,
    required double tolerance,
  }) =>
      _ProjectedPolyline._(
        polyline: projectedElement.polyline,
        points: simplifyPoints(
          points: projectedElement.points,
          tolerance: tolerance,
          highQuality: true,
        ),
      );

  @override
  List<Polyline<R>> get elements => widget.polylines;

  @override
  Widget build(BuildContext context) {
    super.build(context);

    final camera = MapCamera.of(context);

    final culled = widget.cullingMargin == null
        ? simplifiedElements.toList()
        : _aggressivelyCullPolylines(
      projection: camera.crs.projection,
      polylines: simplifiedElements,
      camera: camera,
      cullingMargin: widget.cullingMargin!,
    ).toList();

    return MobileLayerTransformer(
      child: CustomPaint(
        painter: _PolylinePainter(
          polylines: culled,
          camera: camera,
          hitNotifier: widget.hitNotifier,
          minimumHitbox: widget.minimumHitbox,
        ),
        size: camera.size,
      ),
    );
  }

  Iterable<_ProjectedPolyline<R>> _aggressivelyCullPolylines({
    required Projection projection,
    required Iterable<_ProjectedPolyline<R>> polylines,
    required MapCamera camera,
    required double cullingMargin,
  }) sync* {
    final bounds = camera.visibleBounds;
    final margin = cullingMargin / math.pow(2, camera.zoom);

    // The min(-90), max(180), ... are used to get around the limits of LatLng
    // the value cannot be greater or smaller than that
    final boundsAdjusted = LatLngBounds.unsafe(
      west: math.max(LatLngBounds.minLongitude, bounds.west - margin),
      east: math.min(LatLngBounds.maxLongitude, bounds.east + margin),
      south: math.max(LatLngBounds.minLatitude, bounds.south - margin),
      north: math.min(LatLngBounds.maxLatitude, bounds.north + margin),
    );

    // segment is visible
    final projBounds = Rect.fromPoints(
      projection.project(boundsAdjusted.southWest),
      projection.project(boundsAdjusted.northEast),
    );

    final (xWest, _) = projection.projectXY(const LatLng(0, -180));
    final (xEast, _) = projection.projectXY(const LatLng(0, 180));
    for (final projectedPolyline in polylines) {
      final polyline = projectedPolyline.polyline;
      final boundingBox = polyline.boundingBox;

      /// Check if the camera and the polyline overlap, latitude-wise.
      bool isOverlappingLatitude() {
        if (boundsAdjusted.north < boundingBox.south) {
          return false;
        }
        if (boundsAdjusted.south > boundingBox.north) {
          return false;
        }
        return true;
      }

      /// Check if the camera and the polyline overlap, longitude-wise.
      bool isOverlappingLongitude() {
        if (boundsAdjusted.east < boundingBox.west) {
          return false;
        }
        if (boundsAdjusted.west > boundingBox.east) {
          return false;
        }
        return true;
      }

      /// Check if the camera longitude bounds are reliable, world-wise.
      bool areLongitudeBoundsReliable() {
        if (boundsAdjusted.east == LatLngBounds.maxLongitude) {
          return false;
        }
        if (boundsAdjusted.west == LatLngBounds.minLongitude) {
          return false;
        }
        return true;
      }

      // Test bounding boxes to avoid potentially expensive aggressive culling
      // when none of the line is visible
      // First check, bullet-proof, focusing on latitudes.
      if (!isOverlappingLatitude()) continue;

      // Gradient polylines cannot be easily segmented
      if (polyline.gradientColors != null) {
        yield projectedPolyline;
        continue;
      }

      /// Returns true if the points stretch on different versions of the world.
      bool stretchesBeyondTheLimits() {
        for (final point in projectedPolyline.points) {
          if (point.dx > xEast || point.dx < xWest) {
            return true;
          }
        }
        return false;
      }

      // TODO: think about how to cull polylines that go beyond -180/180.
      // As the notions of projected west/east as min/max are not reliable.
      if (stretchesBeyondTheLimits()) {
        yield projectedPolyline;
        continue;
      }

      // TODO: think about how to cull when the camera bounds go beyond -180/180.
      if (!areLongitudeBoundsReliable()) {
        yield projectedPolyline;
        continue;
      }

      // Test bounding boxes to avoid potentially expensive aggressive culling
      // when none of the line is visible. Here, focusing on longitudes.
      if (!isOverlappingLongitude()) continue;

      // pointer that indicates the start of the visible polyline segment
      int start = -1;
      bool containsSegment = false;
      for (int i = 0; i < projectedPolyline.points.length - 1; i++) {
        // Current segment (p1, p2).
        final p1 = projectedPolyline.points[i];
        final p2 = projectedPolyline.points[i + 1];

        containsSegment =
            projBounds.aabbContainsLine(p1.dx, p1.dy, p2.dx, p2.dy);
        if (containsSegment) {
          if (start == -1) {
            start = i;
          }
        } else {
          // If we cannot see this segment but have seen previous ones, flush the last polyline fragment.
          if (start != -1) {
            yield _ProjectedPolyline._(
              polyline: polyline,
              points: projectedPolyline.points.sublist(start, i + 1),
            );

            // Reset start.
            start = -1;
          }
        }
      }

      // If the last segment was visible push that last visible polyline
      // fragment, which may also be the entire polyline if `start == 0`.
      if (containsSegment) {
        yield start == 0
            ? projectedPolyline
            : _ProjectedPolyline._(
          polyline: polyline,
          // Special case: the entire polyline is visible
          points: projectedPolyline.points.sublist(start),
        );
      }
    }
  }
}

@immutable
class _ProjectedPolyline<R extends Object> with HitDetectableElement<R> {
  final Polyline<R> polyline;
  final List<Offset> points;

  @override
  R? get hitValue => polyline.hitValue;

  const _ProjectedPolyline._({
    required this.polyline,
    required this.points,
  });

  _ProjectedPolyline._fromPolyline(
      Projection projection,
      Polyline<R> polyline,
      bool drawInSingleWorld,
      ) : this._(
    polyline: polyline,
    points: projection.projectList(
      polyline.points,
      projectToSingleWorld: drawInSingleWorld,
    ),
  );
}

/// The [CustomPainter] used to draw [Polyline]s for the [PolylineLayer].
// TODO: We should consider exposing this publicly, as with [CirclePainter] -
// but the projected objects are private at the moment.
class _PolylinePainter<R extends Object> extends CustomPainter
    with HitDetectablePainter<R, _ProjectedPolyline<R>>, FeatureLayerUtils {
  final List<_ProjectedPolyline<R>> polylines;
  final double minimumHitbox;

  @override
  final MapCamera camera;

  @override
  final LayerHitNotifier<R>? hitNotifier;

  /// Create a new [_PolylinePainter] instance
  _PolylinePainter({
    required this.polylines,
    required this.minimumHitbox,
    required this.camera,
    required this.hitNotifier,
  }) {
    _helper = OffsetHelper(camera: camera);
  }

  late final OffsetHelper _helper;

  @override
  bool elementHitTest(
      _ProjectedPolyline<R> projectedPolyline, {
        required Offset point,
        required LatLng coordinate,
      }) {
    final polyline = projectedPolyline.polyline;

    // TODO: We should check the bounding box here, for efficiency
    // However, we need to account for:
    //  * map rotation
    //  * extended bbox that accounts for `minimumHitbox`
    //
    // if (!polyline.boundingBox.contains(touch)) {
    //   continue;
    // }

    WorldWorkControl checkIfHit(double shift) {
      final (offsets, _) = _helper.getOffsetsXY(
        points: projectedPolyline.points,
        shift: shift,
      );
      if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible;

      final strokeWidth = polyline.useStrokeWidthInMeter
          ? metersToScreenPixels(
        projectedPolyline.polyline.points.first,
        polyline.strokeWidth,
      )
          : polyline.strokeWidth;
      final hittableDistance = math.max(
        strokeWidth / 2 + polyline.borderStrokeWidth / 2,
        minimumHitbox,
      );

      for (int i = 0; i < offsets.length - 1; i++) {
        final o1 = offsets[i];
        final o2 = offsets[i + 1];

        final distanceSq =
        getSqSegDist(point.dx, point.dy, o1.dx, o1.dy, o2.dx, o2.dy);

        if (distanceSq <= hittableDistance * hittableDistance) {
          return WorldWorkControl.hit;
        }
      }

      return WorldWorkControl.visible;
    }

    return workAcrossWorlds(checkIfHit);
  }

  @override
  Iterable<_ProjectedPolyline<R>> get elements => polylines;

  @override
  void paint(Canvas canvas, Size size) {
    super.paint(canvas, size);

    var path = ui.Path();
    var borderPath = ui.Path();
    var filterPath = ui.Path();
    var paint = Paint();
    var needsLayerSaving = false;

    Paint? borderPaint;
    Paint? filterPaint;
    int? lastHash;

    void drawPaths() {
      final hasBorder = borderPaint != null && filterPaint != null;
      if (hasBorder) {
        if (needsLayerSaving) {
          canvas.saveLayer(viewportRect, Paint());
        }

        canvas.drawPath(borderPath, borderPaint!);
        borderPath = ui.Path();
        borderPaint = null;

        if (needsLayerSaving) {
          canvas.drawPath(filterPath, filterPaint!);
          filterPath = ui.Path();
          filterPaint = null;

          canvas.restore();
        }
      }

      canvas.drawPath(path, paint);
      path = ui.Path();
      paint = Paint();
    }

    for (final projectedPolyline in polylines) {
      final polyline = projectedPolyline.polyline;
      if (polyline.points.isEmpty) {
        continue;
      }

      /// Draws on a "single-world"
      WorldWorkControl drawIfVisible(double shift) {
        final (offsets, _) = _helper.getOffsetsXY(
          points: projectedPolyline.points,
          shift: shift,
        );
        if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible;

        final hash = polyline.renderHashCode;
        if (needsLayerSaving || (lastHash != null && lastHash != hash)) {
          drawPaths();
        }
        lastHash = hash;
        needsLayerSaving = polyline.color.a < 1 ||
            (polyline.gradientColors?.any((c) => c.a < 1) ?? false);

        // strokeWidth, or strokeWidth + borderWidth if relevant.
        late double largestStrokeWidth;

        late final double strokeWidth;
        if (polyline.useStrokeWidthInMeter) {
          strokeWidth = metersToScreenPixels(
            projectedPolyline.polyline.points.first,
            polyline.strokeWidth,
          );
        } else {
          strokeWidth = polyline.strokeWidth;
        }
        largestStrokeWidth = strokeWidth;

        final isSolid = polyline.pattern == const StrokePattern.solid();
        final isDashed = polyline.pattern.segments != null;
        final isDotted = polyline.pattern.spacingFactor != null;

        paint = Paint()
          ..strokeWidth = strokeWidth
          ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke
          ..blendMode = BlendMode.srcOver;

        if (polyline.gradientColors == null) {
          paint.color = polyline.color;
        } else {
          polyline.gradientColors!.isNotEmpty
              ? paint.shader = _paintGradient(polyline, offsets)
              : paint.color = polyline.color;
        }

        if (polyline.borderStrokeWidth > 0.0) {
          // Outlined lines are drawn by drawing a thicker path underneath, then
          // stenciling the middle (in case the line fill is transparent), and
          // finally drawing the line fill.
          largestStrokeWidth = strokeWidth + polyline.borderStrokeWidth;
          borderPaint = Paint()
            ..color = polyline.borderColor
            ..strokeWidth = strokeWidth + polyline.borderStrokeWidth
            ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke
            ..blendMode = BlendMode.srcOver;

          filterPaint = Paint()
            ..color = polyline.borderColor.withAlpha(255)
            ..strokeWidth = strokeWidth
            ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke
            ..blendMode = BlendMode.dstOut;
        }

        final radius = paint.strokeWidth / 2;
        final borderRadius = (borderPaint?.strokeWidth ?? 0) / 2;

        final List<ui.Path> paths = [];
        if (borderPaint != null && filterPaint != null) {
          paths.add(borderPath);
          paths.add(filterPath);
        }
        paths.add(path);
        if (isSolid) {
          final SolidPixelHiker hiker = SolidPixelHiker(
            offsets: offsets,
            closePath: false,
            canvasSize: size,
            strokeWidth: largestStrokeWidth,
          );
          hiker.addAllVisibleSegments(paths);
        } else if (isDotted) {
          final DottedPixelHiker hiker = DottedPixelHiker(
            offsets: offsets,
            stepLength: strokeWidth * polyline.pattern.spacingFactor!,
            patternFit: polyline.pattern.patternFit!,
            closePath: false,
            canvasSize: size,
            strokeWidth: largestStrokeWidth,
          );

          final List<double> radii = [];
          if (borderPaint != null && filterPaint != null) {
            radii.add(borderRadius);
            radii.add(radius);
          }
          radii.add(radius);

          for (final visibleDot in hiker.getAllVisibleDots()) {
            for (int i = 0; i < paths.length; i++) {
              paths[i].addOval(
                  Rect.fromCircle(center: visibleDot, radius: radii[i]));
            }
          }
        } else if (isDashed) {
          final DashedPixelHiker hiker = DashedPixelHiker(
            offsets: offsets,
            segmentValues: polyline.pattern.segments!,
            patternFit: polyline.pattern.patternFit!,
            closePath: false,
            canvasSize: size,
            strokeWidth: largestStrokeWidth,
          );


          for (final visibleSegment in hiker.getAllVisibleSegments()) {
            final dx = visibleSegment.end.dx - visibleSegment.begin.dx;
            final dy = visibleSegment.end.dy - visibleSegment.begin.dy;
            var angle = atan2(dy, dx);

            for (final path in paths) {
              buildTriangle(visibleSegment.begin, strokeWidth, angle, path);
            }
          }
        }

        return WorldWorkControl.visible;
      }

      workAcrossWorlds(drawIfVisible);
    }

    drawPaths();
  }

  void buildTriangle(Offset center, double size, double angleRad, ui.Path path) {
    final r = size;
    final alpha = pi / 6; // 30度展开角
    final baseR = r * 0.6;

    final p0 = Offset(center.dx + r * cos(angleRad),
        center.dy + r * sin(angleRad));
    final p1 = Offset(center.dx + baseR * cos(angleRad + alpha),
        center.dy + baseR * sin(angleRad + alpha));
    final p2 = Offset(center.dx + baseR * cos(angleRad - alpha),
        center.dy + baseR * sin(angleRad - alpha));

    path
      ..moveTo(p0.dx, p0.dy)
      ..lineTo(p1.dx, p1.dy)
      ..lineTo(p2.dx, p2.dy)
      ..close();
  }

  ui.Gradient _paintGradient(Polyline polyline, List<Offset> offsets) =>
      ui.Gradient.linear(offsets.first, offsets.last, polyline.gradientColors!,
          _getColorsStop(polyline));

  List<double>? _getColorsStop(Polyline polyline) =>
      (polyline.colorsStop != null &&
          polyline.colorsStop!.length == polyline.gradientColors!.length)
          ? polyline.colorsStop
          : _calculateColorsStop(polyline);

  List<double> _calculateColorsStop(Polyline polyline) {
    final colorsStopInterval = 1.0 / polyline.gradientColors!.length;
    return polyline.gradientColors!
        .map((gradientColor) =>
    polyline.gradientColors!.indexOf(gradientColor) *
        colorsStopInterval)
        .toList();
  }

  @override
  bool shouldRepaint(_PolylinePainter<R> oldDelegate) =>
      polylines != oldDelegate.polylines ||
          camera != oldDelegate.camera ||
          hitNotifier != oldDelegate.hitNotifier ||
          minimumHitbox != oldDelegate.minimumHitbox;
}
class RasterLayer{
  static Widget build(BuildContext context, {required String urlTemplate, required int tileMaxZoom, required int mapMaxZoom}){
    return TileLayer(
      urlTemplate: urlTemplate,
      userAgentPackageName: 'com.demo.test',
      maxZoom: mapMaxZoom.toDouble(),
      maxNativeZoom: tileMaxZoom,
      tileProvider: NetworkTileProvider(),
    );
  }
}
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';

import 'MyPolylineLayer.dart' as custom;
import 'RasterLayer.dart';

class UIMap extends StatefulWidget{
  @override
  State<StatefulWidget> createState() => _State();
}

class _State extends State<UIMap>{

  final mapController = MapController();
  final points = <LatLng>[];
  
  @override
  Widget build(BuildContext context) {
    return FlutterMap(
      mapController: mapController,
      options: MapOptions(
        initialCenter: LatLng(47.0451022, -122.8950075),
        initialZoom: 16,
        maxZoom: 20,
        onTap: (tap, point){
          points.add(point);
          if(points.length < 2) return;
          setState(() {});
        },
      ),
      children: [
        RasterLayer.build(context, urlTemplate: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png', tileMaxZoom: 16, mapMaxZoom: 20),
        if(points.isNotEmpty) custom.PolylineLayer(
          polylines: [
            Polyline(points: points, color: Colors.blue, strokeWidth: 10),
            Polyline(points: points, color: Colors.black54, strokeWidth: 10, pattern: StrokePattern.dashed(segments: [10, 40])),
          ],
        )
      ],
    );
  }

}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions