diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index 4656f83a4..fa8005b08 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -135,6 +135,19 @@ class _PolylinePainter extends CustomPainter ); if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible; + if (polyline is MulticolorPolyline) { + drawPaths(); + lastHash = null; + needsLayerSaving = false; + _drawMulticolorPolyline( + canvas: canvas, + size: size, + projectedPolyline: projectedPolyline, + offsets: offsets, + ); + return WorldWorkControl.visible; + } + final hash = polyline.renderHashCode; if (needsLayerSaving || (lastHash != null && lastHash != hash)) { drawPaths(); @@ -265,6 +278,102 @@ class _PolylinePainter extends CustomPainter drawPaths(); } + void _drawMulticolorPolyline({ + required Canvas canvas, + required Size size, + required _ProjectedPolyline projectedPolyline, + required List offsets, + }) { + final polyline = projectedPolyline.polyline as MulticolorPolyline; + + final colors = polyline.resolvedVertexColors; + final vertexCount = math.min(offsets.length, colors.length); + if (vertexCount < 2) { + return; + } + + final strokeWidth = polyline.useStrokeWidthInMeter + ? metersToScreenPixels( + projectedPolyline.polyline.points.first, + polyline.strokeWidth, + ) + : polyline.strokeWidth; + + final hasBorder = polyline.borderStrokeWidth > 0.0; + final requiresLayerSaving = polyline.hasTransparentVertices; + + if (hasBorder) { + final borderPath = ui.Path(); + final filterPath = ui.Path(); + final paths = [borderPath]; + if (requiresLayerSaving) { + paths.add(filterPath); + } + + final SolidPixelHiker borderHiker = SolidPixelHiker( + offsets: offsets, + closePath: false, + canvasSize: size, + strokeWidth: strokeWidth + polyline.borderStrokeWidth, + ); + borderHiker.addAllVisibleSegments(paths); + + final borderPaint = Paint() + ..color = polyline.borderColor + ..strokeWidth = strokeWidth + polyline.borderStrokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = PaintingStyle.stroke + ..blendMode = BlendMode.srcOver; + + final filterPaint = Paint() + ..color = polyline.borderColor.withAlpha(255) + ..strokeWidth = strokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = PaintingStyle.stroke + ..blendMode = BlendMode.dstOut; + + if (requiresLayerSaving) { + canvas.saveLayer(viewportRect, Paint()); + } + + canvas.drawPath(borderPath, borderPaint); + + if (requiresLayerSaving) { + canvas.drawPath(filterPath, filterPaint); + canvas.restore(); + } + } + + final strokePaint = Paint() + ..strokeWidth = strokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = PaintingStyle.stroke + ..blendMode = BlendMode.srcOver; + + final segmentPath = ui.Path(); + + for (int i = 0; i < vertexCount - 1; i++) { + final start = offsets[i]; + final end = offsets[i + 1]; + if (start == end) continue; + + strokePaint.shader = ui.Gradient.linear( + start, + end, + [colors[i], colors[i + 1]], + ); + segmentPath.moveTo(start.dx, start.dy); + segmentPath.lineTo(end.dx, end.dy); + canvas.drawPath(segmentPath, strokePaint); + segmentPath.reset(); + } + + strokePaint.shader = null; + } + ui.Gradient _paintGradient(Polyline polyline, List offsets) => ui.Gradient.linear(offsets.first, offsets.last, polyline.gradientColors!, _getColorsStop(polyline)); diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart index b5712da66..d899be516 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -104,3 +104,97 @@ class Polyline with HitDetectableElement { @override int get hashCode => _hashCode ??= Object.hashAll([...points, renderHashCode]); } + +/// A [Polyline] variant that can display a different color at each vertex and +/// paints a smooth gradient between consecutive vertices. +class MulticolorPolyline extends Polyline { + /// The color applied at each vertex in [points]. + /// + /// When `null` or empty, [defaultColor] is used instead. + final List? vertexColors; + + /// The fallback color used when no [vertexColors] are provided. + final Color defaultColor; + + int? _multicolorRenderHashCode; + List? _resolvedColors; + + /// Create a multicolor polyline that interpolates between the supplied + /// [vertexColors]. + MulticolorPolyline({ + required List points, + List? vertexColors, + this.defaultColor = const Color(0xFF00FF00), + double strokeWidth = 1.0, + StrokePattern pattern = const StrokePattern.solid(), + double borderStrokeWidth = 0.0, + Color borderColor = const Color(0xFFFFFF00), + StrokeCap strokeCap = StrokeCap.round, + StrokeJoin strokeJoin = StrokeJoin.round, + bool useStrokeWidthInMeter = false, + R? hitValue, + }) : assert( + vertexColors == null || + vertexColors.isEmpty || + points.length == vertexColors.length, + 'vertexColors length must match points length', + ), + assert(points.length >= 2, + 'MulticolorPolyline requires at least two points'), + assert( + pattern == const StrokePattern.solid(), + 'MulticolorPolyline currently supports only solid stroke patterns.', + ), + vertexColors = vertexColors != null && vertexColors.isNotEmpty + ? vertexColors + : null, + super( + points: points, + strokeWidth: strokeWidth, + pattern: pattern, + color: (vertexColors != null && vertexColors.isNotEmpty) + ? vertexColors.first + : defaultColor, + borderStrokeWidth: borderStrokeWidth, + borderColor: borderColor, + gradientColors: null, + colorsStop: null, + strokeCap: strokeCap, + strokeJoin: strokeJoin, + useStrokeWidthInMeter: useStrokeWidthInMeter, + hitValue: hitValue, + ); + + /// Returns `true` when any effective vertex color is translucent. + bool get hasTransparentVertices => + resolvedVertexColors.any((color) => color.alpha < 0xFF); + + /// Returns `true` when at least two distinct vertex colors were provided. + bool get hasGradientStops => + vertexColors != null && vertexColors!.length >= 2; + + /// Returns the colors used for painting, falling back to [defaultColor] when + /// custom [vertexColors] are not provided. + List get resolvedVertexColors => _resolvedColors ??= vertexColors ?? + List.filled(points.length, defaultColor, growable: false); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MulticolorPolyline && + super == other && + defaultColor == other.defaultColor && + _listEqualsNullable(vertexColors, other.vertexColors)); + + @override + int get renderHashCode => _multicolorRenderHashCode ??= Object.hash( + super.renderHashCode, + defaultColor, + vertexColors == null ? null : Object.hashAll(vertexColors!)); +} + +bool _listEqualsNullable(List? a, List? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return a == null && b == null; + return listEquals(a, b); +} diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index 6da20ffc5..da15010cc 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -93,15 +93,21 @@ class _PolylineLayerState extends State> _ProjectedPolyline simplifyProjectedElement({ required _ProjectedPolyline projectedElement, required double tolerance, - }) => - _ProjectedPolyline._( - polyline: projectedElement.polyline, - points: simplifyPoints( - points: projectedElement.points, - tolerance: tolerance, - highQuality: true, - ), - ); + }) { + final polyline = projectedElement.polyline; + if (polyline is MulticolorPolyline) { + return projectedElement; + } + + return _ProjectedPolyline._( + polyline: polyline, + points: simplifyPoints( + points: projectedElement.points, + tolerance: tolerance, + highQuality: true, + ), + ); + } @override List> get elements => widget.polylines; @@ -202,8 +208,9 @@ class _PolylineLayerState extends State> // First check, bullet-proof, focusing on latitudes. if (!isOverlappingLatitude()) continue; - // Gradient polylines cannot be easily segmented - if (polyline.gradientColors != null) { + // Gradient or multicolor polylines cannot be easily segmented + if (polyline.gradientColors != null || + polyline is MulticolorPolyline) { yield projectedPolyline; continue; } diff --git a/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index 9fdf43f44..d36393b82 100644 --- a/test/layer/polyline_layer_test.dart +++ b/test/layer/polyline_layer_test.dart @@ -31,4 +31,53 @@ void main() { of: find.byType(PolylineLayer), matching: find.byType(CustomPaint)), findsOneWidget); }); + + testWidgets('multicolor polyline renders without errors', (tester) async { + final polylines = [ + MulticolorPolyline( + points: const [ + LatLng(50.5, -0.09), + LatLng(51.3498, -6.2603), + LatLng(53.8566, 2.3522), + ], + vertexColors: const [ + Colors.red, + Colors.orange, + Colors.blue, + ], + strokeWidth: 6, + ), + ]; + + await tester.pumpWidget(TestApp(polylines: polylines)); + + expect(find.byType(FlutterMap), findsOneWidget); + expect(find.byType(PolylineLayer), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('multicolor polyline falls back to defaultColor', (tester) async { + final polylines = [ + MulticolorPolyline( + points: const [ + LatLng(52.5, -0.09), + LatLng(53.3498, -6.2603), + LatLng(55.8566, 2.3522), + ], + defaultColor: Colors.purple, + strokeWidth: 5, + ), + ]; + + await tester.pumpWidget(TestApp(polylines: polylines)); + + expect(find.byType(FlutterMap), findsOneWidget); + expect(find.byType(PolylineLayer), findsOneWidget); + expect(tester.takeException(), isNull); + + final polyline = polylines.first as MulticolorPolyline; + expect(polyline.vertexColors, isNull); + expect(polyline.color, equals(Colors.purple)); + expect(polyline.resolvedVertexColors, everyElement(equals(Colors.purple))); + }); }