Skip to content

Add customisable decoration to Polylines via _PixelHiker #2180

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])),
          ],
        )
      ],
    );
  }

}

Activity

  1. added theissue type on Jan 16, 2026
  2. JaffaKetchup commented on Jan 21, 2026

    @JaffaKetchup
    Member

    Hey, thanks for opening this, it looks very interesting!
    I think we could add something like this into the core, although we would need to leave it to the user to define the 'decoration' painter.

  3. monsieurtanuki commented on Jan 25, 2026

    @monsieurtanuki
    Contributor

    Would rely on the "hiker" feature.

    A bit like in #2172: we could imagine a callback (Offset point, double angle)?, to be called for each step of the hiker.

    First steps may be to

    • imagine polylines without actual lines, just hiking
    • a standard "arrow painter", used to draw an arrow at each step.

    For performance reasons, we may then consider a list of hikers for the same polyline: we would have to follow the polyline path just once.

  4. JaffaKetchup commented on Mar 1, 2026

    @JaffaKetchup
    Member

    That's a good idea. A polyline is formed from a list of hikers - for example, one for the line itself, then multiple decals. We could reimagine StrokePattern instead as prebuilt hikers (each with options like we have now) instead of being 'switch conditions' like they are now, and add a 'decal' hiker which is customisable.

  5. changed the title [-]About the implementation method of Arrow PolyLine[/-] [+]Add customisable decoration to `Polyline`s via `_PixelHiker`[/+] on Mar 1, 2026
  6. updated Priority Medium and Effort High on Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Fields

    Priority

    Medium

    Effort

    High

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions