Repository navigation
Open
Labels
Description
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])),
],
)
],
);
}
}Reacted by Herbert Poul and João Vitor R
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Medium
Effort
High
Activity
JaffaKetchup commentedon Jan 21, 2026
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.
monsieurtanuki commentedon Jan 25, 2026
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
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.
JaffaKetchup commentedon Mar 1, 2026
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
StrokePatterninstead 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.[-]About the implementation method of Arrow PolyLine[/-][+]Add customisable decoration to `Polyline`s via `_PixelHiker`[/+]