From 1f98f97fbf266c07487001b2965854b163116c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Bobi=C5=84ski?= Date: Wed, 16 Sep 2026 17:20:04 +0200 Subject: [PATCH] feat: add optional glow and shadow effects to polylines and circles Adds `ShapeGlow` and `ShapeShadow`, set per `Polyline` and `CircleMarker`. Effects are painted beneath the feature, following its outermost outline (the border when present, or only the ring of a transparent circle), and nothing extra is painted when they are unset. Co-Authored-By: Claude Opus 5 (1M context) --- lib/flutter_map.dart | 2 + lib/src/layer/circle_layer/circle_layer.dart | 1 + lib/src/layer/circle_layer/circle_marker.dart | 14 +++ lib/src/layer/circle_layer/painter.dart | 90 +++++++++++++++++ lib/src/layer/polyline_layer/painter.dart | 54 ++++++++++- lib/src/layer/polyline_layer/polyline.dart | 12 +++ .../layer/polyline_layer/polyline_layer.dart | 1 + lib/src/layer/shared/shape_effects.dart | 95 ++++++++++++++++++ test/layer/circle_layer_test.dart | 97 +++++++++++++++++++ test/layer/polyline_layer_test.dart | 62 ++++++++++++ 10 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 lib/src/layer/shared/shape_effects.dart diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 21fa967b3..4e4f335fe 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -39,6 +39,8 @@ export 'package:flutter_map/src/layer/shared/layer_interactivity/layer_hit_notif export 'package:flutter_map/src/layer/shared/layer_interactivity/layer_hit_result.dart'; export 'package:flutter_map/src/layer/shared/line_patterns/stroke_pattern.dart'; export 'package:flutter_map/src/layer/shared/mobile_layer_transformer.dart'; +export 'package:flutter_map/src/layer/shared/shape_effects.dart' + hide shapeEffectsExtent; export 'package:flutter_map/src/layer/shared/translucent_pointer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; diff --git a/lib/src/layer/circle_layer/circle_layer.dart b/lib/src/layer/circle_layer/circle_layer.dart index 4fc6b86e8..5e9352f6b 100644 --- a/lib/src/layer/circle_layer/circle_layer.dart +++ b/lib/src/layer/circle_layer/circle_layer.dart @@ -5,6 +5,7 @@ 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/shape_effects.dart'; import 'package:latlong2/latlong.dart' hide Path; part 'circle_marker.dart'; diff --git a/lib/src/layer/circle_layer/circle_marker.dart b/lib/src/layer/circle_layer/circle_marker.dart index d193398ca..2271861f5 100644 --- a/lib/src/layer/circle_layer/circle_marker.dart +++ b/lib/src/layer/circle_layer/circle_marker.dart @@ -29,6 +29,18 @@ class CircleMarker with HitDetectableElement { /// Whether to treat [radius] as a measurement in meters instead of pixels. final bool useRadiusInMeter; + /// Optional halo painted around the circle + /// + /// Follows the filled disk, or only the border ring when [color] is fully + /// transparent. + final ShapeGlow? glow; + + /// Optional shadow painted underneath the circle + /// + /// Follows the filled disk, or only the border ring when [color] is fully + /// transparent. + final ShapeShadow? shadow; + @override final R? hitValue; @@ -41,6 +53,8 @@ class CircleMarker with HitDetectableElement { this.color = const Color(0xFF00FF00), this.borderStrokeWidth = 0.0, this.borderColor = const Color(0xFFFFFF00), + this.glow, + this.shadow, this.hitValue, }); } diff --git a/lib/src/layer/circle_layer/painter.dart b/lib/src/layer/circle_layer/painter.dart index a947e9903..4fde97736 100644 --- a/lib/src/layer/circle_layer/painter.dart +++ b/lib/src/layer/circle_layer/painter.dart @@ -60,12 +60,16 @@ class CirclePainter extends CustomPainter final points = >>{}; final pointsFilledBorder = >>{}; final pointsBorder = >>>{}; + final shadows = >{}; + final glows = >{}; _pixelsPerMeter = null; for (final circle in circles) { final radiusWithoutBorder = _getRadiusInPixel(circle); final radiusWithBorder = radiusWithoutBorder + circle.borderStrokeWidth / 2; final initialCenter = _getOffset(circle.point); + final hasEffects = circle.glow != null || circle.shadow != null; + final effectsExtent = shapeEffectsExtent(circle.glow, circle.shadow); /// Draws on a "single-world" WorldWorkControl drawIfVisible(double shift) { @@ -80,6 +84,23 @@ class CirclePainter extends CustomPainter return false; } + if (hasEffects && isVisible(radiusWithBorder + effectsExtent)) { + final outline = _CircleOutline.of( + circle, + center: center, + radiusWithoutBorder: radiusWithoutBorder, + radiusWithBorder: radiusWithBorder, + ); + if (outline != null) { + if (circle.shadow case final shadow?) { + (shadows[shadow] ??= []).add(outline); + } + if (circle.glow case final glow?) { + (glows[glow] ??= []).add(outline); + } + } + } + if (isVisible(radiusWithoutBorder)) { points[circle.color] ??= {}; points[circle.color]![radiusWithoutBorder] ??= []; @@ -113,6 +134,24 @@ class CirclePainter extends CustomPainter // Now that all the points are grouped, let's draw them + // Effects first, so that they stay below every disk and border + for (final MapEntry(key: shadow, value: outlines) in shadows.entries) { + final paint = shadow.toPaint(); + canvas + ..save() + ..translate(shadow.offset.dx, shadow.offset.dy); + for (final outline in outlines) { + outline.paint(canvas, paint); + } + canvas.restore(); + } + for (final MapEntry(key: glow, value: outlines) in glows.entries) { + final paint = glow.toPaint(); + for (final outline in outlines) { + outline.paint(canvas, paint); + } + } + // First, the border when with non opaque disk final paintBorder = Paint()..style = PaintingStyle.stroke; for (final color in pointsBorder.keys) { @@ -194,3 +233,54 @@ class CirclePainter extends CustomPainter viewportRect .overlaps(Rect.fromCircle(center: center, radius: radiusInPixel)); } + +/// Outermost painted geometry of a [CircleMarker], used to draw its effects +@immutable +class _CircleOutline { + final Offset center; + final double radius; + + /// Stroke width when only the border ring is painted, or `null` for the + /// filled disk + final double? ringWidth; + + const _CircleOutline._({ + required this.center, + required this.radius, + required this.ringWidth, + }); + + static _CircleOutline? of( + CircleMarker circle, { + required Offset center, + required double radiusWithoutBorder, + required double radiusWithBorder, + }) { + if (circle.color.a > 0) { + return _CircleOutline._( + center: center, + radius: radiusWithBorder, + ringWidth: null, + ); + } + if (circle.borderStrokeWidth > 0 && circle.borderColor.a > 0) { + return _CircleOutline._( + center: center, + radius: radiusWithoutBorder, + ringWidth: circle.borderStrokeWidth, + ); + } + return null; + } + + void paint(Canvas canvas, Paint paint) { + if (ringWidth case final width?) { + paint + ..style = PaintingStyle.stroke + ..strokeWidth = width; + } else { + paint.style = PaintingStyle.fill; + } + canvas.drawCircle(center, radius, paint); + } +} diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index 4d14e9228..50121ecba 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -98,10 +98,40 @@ class _PolylinePainter extends CustomPainter Paint? borderPaint; Paint? filterPaint; + ShapeGlow? glow; + ShapeShadow? shadow; int? lastHash; void drawPaths() { final hasBorder = borderPaint != null && filterPaint != null; + + // Effects follow the outermost outline, and sit below the layer that + // stencils translucent lines + if (glow != null || shadow != null) { + final outlinePath = hasBorder ? borderPath : path; + final outlinePaint = hasBorder ? borderPaint! : paint; + + if (shadow case final effect?) { + canvas + ..save() + ..translate(effect.offset.dx, effect.offset.dy) + ..drawPath( + outlinePath, + _outlineEffectPaint(effect.toPaint(), outlinePaint), + ) + ..restore(); + } + if (glow case final effect?) { + canvas.drawPath( + outlinePath, + _outlineEffectPaint(effect.toPaint(), outlinePaint), + ); + } + + glow = null; + shadow = null; + } + if (hasBorder) { if (needsLayerSaving) { canvas.saveLayer(viewportRect, Paint()); @@ -148,7 +178,10 @@ class _PolylinePainter extends CustomPainter strokeWidth = polyline.strokeWidth; } - if (!areOffsetsVisible(offsets, strokeWidth)) { + final effectsExtent = + shapeEffectsExtent(polyline.glow, polyline.shadow); + + if (!areOffsetsVisible(offsets, strokeWidth + 2 * effectsExtent)) { return WorldWorkControl.invisible; } @@ -160,6 +193,9 @@ class _PolylinePainter extends CustomPainter needsLayerSaving = polyline.color.a < 1 || (polyline.gradientColors?.any((c) => c.a < 1) ?? false); + glow = polyline.glow; + shadow = polyline.shadow; + // strokeWidth, or strokeWidth + borderWidth if relevant. double largestStrokeWidth = strokeWidth; @@ -204,6 +240,9 @@ class _PolylinePainter extends CustomPainter ..blendMode = BlendMode.dstOut; } + // Keep segments whose effects reach into the viewport + final hikerStrokeWidth = largestStrokeWidth + 2 * effectsExtent; + final radius = paint.strokeWidth / 2; final borderRadius = (borderPaint?.strokeWidth ?? 0) / 2; @@ -218,7 +257,7 @@ class _PolylinePainter extends CustomPainter offsets: offsets, closePath: false, canvasSize: size, - strokeWidth: largestStrokeWidth, + strokeWidth: hikerStrokeWidth, ); hiker.addAllVisibleSegments(paths); } else if (isDotted) { @@ -228,7 +267,7 @@ class _PolylinePainter extends CustomPainter patternFit: polyline.pattern.patternFit!, closePath: false, canvasSize: size, - strokeWidth: largestStrokeWidth, + strokeWidth: hikerStrokeWidth, ); final List radii = []; @@ -251,7 +290,7 @@ class _PolylinePainter extends CustomPainter patternFit: polyline.pattern.patternFit!, closePath: false, canvasSize: size, - strokeWidth: largestStrokeWidth, + strokeWidth: hikerStrokeWidth, ); for (final visibleSegment in hiker.getAllVisibleSegments()) { @@ -271,6 +310,13 @@ class _PolylinePainter extends CustomPainter drawPaths(); } + Paint _outlineEffectPaint(Paint effectPaint, Paint outlinePaint) => + effectPaint + ..strokeWidth = outlinePaint.strokeWidth + ..strokeCap = outlinePaint.strokeCap + ..strokeJoin = outlinePaint.strokeJoin + ..style = outlinePaint.style; + 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..a8201955a 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -39,6 +39,12 @@ class Polyline with HitDetectableElement { /// Set to true if the width of the stroke should have meters as unit. final bool useStrokeWidthInMeter; + /// Optional halo painted around the line (and its border) + final ShapeGlow? glow; + + /// Optional shadow painted underneath the line (and its border) + final ShapeShadow? shadow; + @override final R? hitValue; @@ -61,6 +67,8 @@ class Polyline with HitDetectableElement { this.strokeCap = StrokeCap.round, this.strokeJoin = StrokeJoin.round, this.useStrokeWidthInMeter = false, + this.glow, + this.shadow, this.hitValue, }); @@ -76,6 +84,8 @@ class Polyline with HitDetectableElement { strokeCap == other.strokeCap && strokeJoin == other.strokeJoin && useStrokeWidthInMeter == other.useStrokeWidthInMeter && + glow == other.glow && + shadow == other.shadow && hitValue == other.hitValue && // Expensive computations last to take advantage of lazy logic gates listEquals(colorsStop, other.colorsStop) && @@ -97,6 +107,8 @@ class Polyline with HitDetectableElement { strokeCap, strokeJoin, useStrokeWidthInMeter, + glow, + shadow, ); int? _hashCode; diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index a705802b6..3577dbbd0 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -10,6 +10,7 @@ import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_de 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/layer/shared/shape_effects.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'; diff --git a/lib/src/layer/shared/shape_effects.dart b/lib/src/layer/shared/shape_effects.dart new file mode 100644 index 000000000..45419fdc0 --- /dev/null +++ b/lib/src/layer/shared/shape_effects.dart @@ -0,0 +1,95 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +/// A soft halo painted only outside a feature's outline +/// +/// Supported by `Polyline`s and `CircleMarker`s. The outline includes any +/// border. +@immutable +class ShapeGlow { + /// Color of the halo, including its opacity + final Color color; + + /// Standard deviation of the Gaussian blur, in logical pixels + /// + /// Defaults to 6. + final double blurSigma; + + /// Create a new [ShapeGlow] + const ShapeGlow({required this.color, this.blurSigma = 6}) + : assert(blurSigma >= 0, 'blurSigma cannot be negative'); + + /// How far the halo reaches past the outline, in logical pixels + double get extent => blurSigma * 3; + + /// The paint used to draw this effect + Paint toPaint() => Paint() + ..color = color + ..maskFilter = + blurSigma > 0 ? MaskFilter.blur(BlurStyle.outer, blurSigma) : null; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShapeGlow && + color == other.color && + blurSigma == other.blurSigma); + + @override + int get hashCode => Object.hash(color, blurSigma); +} + +/// A blurred, offset copy of a feature's outline painted underneath it +/// +/// Supported by `Polyline`s and `CircleMarker`s. The outline includes any +/// border. +@immutable +class ShapeShadow { + /// Color of the shadow, including its opacity + final Color color; + + /// Standard deviation of the Gaussian blur, in logical pixels + /// + /// Defaults to 4. + final double blurSigma; + + /// Screen space offset of the shadow, in logical pixels + /// + /// Defaults to 2 pixels downwards. + final Offset offset; + + /// Create a new [ShapeShadow] + const ShapeShadow({ + required this.color, + this.blurSigma = 4, + this.offset = const Offset(0, 2), + }) : assert(blurSigma >= 0, 'blurSigma cannot be negative'); + + /// How far the shadow reaches past the outline, in logical pixels + double get extent => + blurSigma * 3 + math.max(offset.dx.abs(), offset.dy.abs()); + + /// The paint used to draw this effect + Paint toPaint() => Paint() + ..color = color + ..maskFilter = + blurSigma > 0 ? MaskFilter.blur(BlurStyle.normal, blurSigma) : null; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShapeShadow && + color == other.color && + blurSigma == other.blurSigma && + offset == other.offset); + + @override + int get hashCode => Object.hash(color, blurSigma, offset); +} + +/// Largest distance any of the effects reach past a feature's outline +@internal +double shapeEffectsExtent(ShapeGlow? glow, ShapeShadow? shadow) => + math.max(glow?.extent ?? 0, shadow?.extent ?? 0); diff --git a/test/layer/circle_layer_test.dart b/test/layer/circle_layer_test.dart index a8cdb5cf7..c2631fac9 100644 --- a/test/layer/circle_layer_test.dart +++ b/test/layer/circle_layer_test.dart @@ -31,4 +31,101 @@ void main() { of: find.byType(CircleLayer), matching: find.byType(CustomPaint)), findsOneWidget); }); + + group('effects', () { + const fillColor = Color(0xFF2196F3); + const borderColor = Color(0xFF0D47A1); + const glowColor = Color(0xFFFF9800); + const shadowColor = Color(0x80000000); + + RenderObject paintedLayer(WidgetTester tester) => tester.renderObject( + find.descendant( + of: find.byType(CircleLayer), + matching: find.byType(CustomPaint), + ), + ); + + testWidgets('paints shadow and glow below the disk', (tester) async { + await tester.pumpWidget( + const TestApp( + circles: [ + CircleMarker( + point: LatLng(45.5231, -122.6765), + radius: 20, + color: fillColor, + borderStrokeWidth: 4, + borderColor: borderColor, + glow: ShapeGlow(color: glowColor), + shadow: ShapeShadow(color: shadowColor), + ), + ], + ), + ); + + expect( + paintedLayer(tester), + paints + ..save() + ..translate(x: 0, y: 2) + ..circle(radius: 22, color: shadowColor, style: PaintingStyle.fill) + ..restore() + ..circle(radius: 22, color: glowColor, style: PaintingStyle.fill) + ..something(_drawsPoints(diameter: 44)) + ..something(_drawsPoints(diameter: 40)), + ); + }); + + testWidgets('follows only the ring when the fill is transparent', ( + tester, + ) async { + await tester.pumpWidget( + const TestApp( + circles: [ + CircleMarker( + point: LatLng(45.5231, -122.6765), + radius: 20, + color: Color(0x00000000), + borderStrokeWidth: 4, + borderColor: borderColor, + glow: ShapeGlow(color: glowColor), + ), + ], + ), + ); + + expect( + paintedLayer(tester), + paints + ..circle( + radius: 20, + color: glowColor, + style: PaintingStyle.stroke, + strokeWidth: 4, + ) + ..circle(radius: 20, color: borderColor, strokeWidth: 4), + ); + }); + + testWidgets('paints no effects when none are set', (tester) async { + await tester.pumpWidget( + const TestApp( + circles: [ + CircleMarker( + point: LatLng(45.5231, -122.6765), + radius: 20, + color: fillColor, + ), + ], + ), + ); + + expect(paintedLayer(tester), paintsExactlyCountTimes(#drawCircle, 0)); + }); + }); } + +// The painter reuses one Paint for points, so match on its recorded width +PaintPatternPredicate _drawsPoints({required double diameter}) => + (method, arguments) => + method == #drawPoints && + (arguments[2] as Paint).strokeWidth == diameter; diff --git a/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index 9fdf43f44..0211707ee 100644 --- a/test/layer/polyline_layer_test.dart +++ b/test/layer/polyline_layer_test.dart @@ -31,4 +31,66 @@ void main() { of: find.byType(PolylineLayer), matching: find.byType(CustomPaint)), findsOneWidget); }); + + group('effects', () { + const lineColor = Color(0xFF2196F3); + const glowColor = Color(0xFFFF9800); + const shadowColor = Color(0x80000000); + + RenderObject paintedLayer(WidgetTester tester) => tester.renderObject( + find.descendant( + of: find.byType(PolylineLayer), + matching: find.byType(CustomPaint), + ), + ); + + Polyline line({ShapeGlow? glow, ShapeShadow? shadow}) => Polyline( + points: const [ + LatLng(45.5231, -122.6865), + LatLng(45.5231, -122.6665) + ], + strokeWidth: 4, + color: lineColor, + glow: glow, + shadow: shadow, + ); + + testWidgets('paints shadow, then glow, then the line', (tester) async { + await tester.pumpWidget( + TestApp( + polylines: [ + line( + glow: const ShapeGlow(color: glowColor), + shadow: const ShapeShadow(color: shadowColor), + ), + ], + ), + ); + + expect( + paintedLayer(tester), + paints + ..save() + ..translate(x: 0, y: 2) + ..path(color: shadowColor, strokeWidth: 4) + ..restore() + ..path(color: glowColor, strokeWidth: 4) + ..path(color: lineColor, strokeWidth: 4), + ); + }); + + testWidgets('paints only the line without effects', (tester) async { + await tester.pumpWidget(TestApp(polylines: [line()])); + + expect(paintedLayer(tester), paintsExactlyCountTimes(#drawPath, 1)); + }); + + test('effects take part in equality and batching', () { + const glow = ShapeGlow(color: glowColor); + + expect(line(glow: glow), line(glow: glow)); + expect(line(glow: glow), isNot(line())); + expect(line(glow: glow).renderHashCode, isNot(line().renderHashCode)); + }); + }); }