diff --git a/example/lib/pages/markers.dart b/example/lib/pages/markers.dart index 5431481d2..2f652096b 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -36,9 +36,18 @@ class _MarkerPageState extends State { Marker buildPin(LatLng point) => Marker( point: point, - child: const Icon(Icons.location_pin, size: 60, color: Colors.black), width: 60, height: 60, + child: GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tapped existing marker'), + duration: Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: const Icon(Icons.location_pin, size: 60, color: Colors.black), + ), ); @override diff --git a/example/lib/pages/polygon.dart b/example/lib/pages/polygon.dart index 04de145bb..5eebc35f4 100644 --- a/example/lib/pages/polygon.dart +++ b/example/lib/pages/polygon.dart @@ -1,73 +1,196 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; -class PolygonPage extends StatelessWidget { +typedef HitValue = ({String title, String subtitle}); + +class PolygonPage extends StatefulWidget { static const String route = '/polygon'; const PolygonPage({super.key}); - final _notFilledPoints = const [ - LatLng(51.5, -0.09), - LatLng(53.3498, -6.2603), - LatLng(48.8566, 2.3522), - ]; - final _filledPoints = const [ - LatLng(55.5, -0.09), - LatLng(54.3498, -6.2603), - LatLng(52.8566, 2.3522), - ]; - final _filledDotedPoints = const [ - LatLng(46.35, 4.94), - LatLng(46.22, -0.11), - LatLng(44.399, 1.76), - ]; - final _labelPoints = const [ - LatLng(60.16, -9.38), - LatLng(60.16, -4.16), - LatLng(61.18, -4.16), - LatLng(61.18, -9.38), - ]; - final _labelRotatedPoints = const [ - LatLng(59.77, -10.28), - LatLng(58.21, -10.28), - LatLng(58.21, -7.01), - LatLng(59.77, -7.01), - LatLng(60.77, -6.01), - ]; - final _normalHoleOuterPoints = const [ - LatLng(50, -18), - LatLng(50, -14), - LatLng(51.5, -12.5), - LatLng(54, -14), - LatLng(54, -18), - ]; - final _brokenHoleOuterPoints = const [ - LatLng(50, -18), - LatLng(53, -16), - LatLng(51.5, -12.5), - LatLng(54, -14), - LatLng(54, -18), - ]; - final _holeInnerPoints = const [ - [ - LatLng(52, -17), - LatLng(52, -16), - LatLng(51.5, -15.5), - LatLng(51, -16), - LatLng(51, -17), - ], - [ - LatLng(53.5, -17), - LatLng(53.5, -16), - LatLng(53, -15), - LatLng(52.25, -15), - LatLng(52.25, -16), - LatLng(52.75, -17), - ], + @override + State createState() => _PolygonPageState(); +} + +class _PolygonPageState extends State { + final LayerHitNotifier _hitNotifier = ValueNotifier(null); + List? _prevHitValues; + List>? _hoverGons; + + final _polygonsRaw = >[ + Polygon( + points: const [ + LatLng(51.5, -0.09), + LatLng(53.3498, -6.2603), + LatLng(48.8566, 2.3522), + ], + borderColor: Colors.red, + borderStrokeWidth: 4, + hitValue: ( + title: 'Basic Unfilled Polygon', + subtitle: 'Nothing really special here...', + ), + ), + Polygon( + points: const [ + LatLng(55.5, -0.09), + LatLng(54.3498, -6.2603), + LatLng(52.8566, 2.3522), + ], + color: Colors.purple, + borderColor: Colors.yellow, + borderStrokeWidth: 4, + hitValue: ( + title: 'Basic Filled Polygon', + subtitle: 'Nothing really special here...', + ), + ), + Polygon( + points: const [ + LatLng(46.35, 4.94), + LatLng(46.22, -0.11), + LatLng(44.399, 1.76), + ], + isDotted: true, + borderStrokeWidth: 4, + borderColor: Colors.lightBlue, + color: Colors.yellow, + hitValue: ( + title: 'Polygon With Dotted Borders', + subtitle: '...', + ), + ), + Polygon( + points: const [ + LatLng(60.16, -9.38), + LatLng(60.16, -4.16), + LatLng(61.18, -4.16), + LatLng(61.18, -9.38), + ], + borderStrokeWidth: 4, + borderColor: Colors.purple, + label: 'Label!', + hitValue: ( + title: 'Polygon With Label', + subtitle: 'This is a very descriptive label!', + ), + ), + Polygon( + points: const [ + LatLng(59.77, -10.28), + LatLng(58.21, -10.28), + LatLng(58.21, -7.01), + LatLng(59.77, -7.01), + LatLng(60.77, -6.01), + ], + borderStrokeWidth: 4, + borderColor: Colors.purple, + label: 'Rotated!', + rotateLabel: true, + labelPlacement: PolygonLabelPlacement.polylabel, + hitValue: ( + title: 'Polygon With Rotated Label', + subtitle: "Now you don't have to turn your head so much", + ), + ), + Polygon( + points: const [ + LatLng(50, -18), + LatLng(50, -14), + LatLng(51.5, -12.5), + LatLng(54, -14), + LatLng(54, -18), + ].map((latlng) => LatLng(latlng.latitude, latlng.longitude + 8)).toList(), + isDotted: true, + holePointsList: [ + const [ + LatLng(52, -17), + LatLng(52, -16), + LatLng(51.5, -15.5), + LatLng(51, -16), + LatLng(51, -17), + ], + const [ + LatLng(53.5, -17), + LatLng(53.5, -16), + LatLng(53, -15), + LatLng(52.25, -15), + LatLng(52.25, -16), + LatLng(52.75, -17), + ], + ] + .map( + (latlngs) => latlngs + .map((latlng) => LatLng(latlng.latitude, latlng.longitude + 8)) + .toList(), + ) + .toList(), + borderStrokeWidth: 4, + borderColor: Colors.orange, + color: Colors.orange.withOpacity(0.5), + label: 'This one is not\nperformantly rendered', + rotateLabel: true, + labelPlacement: PolygonLabelPlacement.centroid, + labelStyle: const TextStyle(color: Colors.black), + hitValue: ( + title: 'Polygon With Hole', + subtitle: 'A bit like Swiss cheese maybe?', + ), + ), + Polygon( + points: const [ + LatLng(50, -18), + LatLng(53, -16), + LatLng(51.5, -12.5), + LatLng(54, -14), + LatLng(54, -18), + ] + .map((latlng) => LatLng(latlng.latitude - 6, latlng.longitude + 8)) + .toList(), + isDotted: true, + holePointsList: [ + const [ + LatLng(52, -17), + LatLng(52, -16), + LatLng(51.5, -15.5), + LatLng(51, -16), + LatLng(51, -17), + ], + const [ + LatLng(53.5, -17), + LatLng(53.5, -16), + LatLng(53, -15), + LatLng(52.25, -15), + LatLng(52.25, -16), + LatLng(52.75, -17), + ], + ] + .map( + (latlngs) => latlngs + .map((latlng) => + LatLng(latlng.latitude - 6, latlng.longitude + 8)) + .toList(), + ) + .toList(), + borderStrokeWidth: 4, + borderColor: Colors.orange, + color: Colors.orange.withOpacity(0.5), + label: 'This one is not\nperformantly rendered', + rotateLabel: true, + labelPlacement: PolygonLabelPlacement.centroid, + labelStyle: const TextStyle(color: Colors.black), + hitValue: ( + title: 'Polygon With Hole & Self Intersection', + subtitle: 'This one still works with performant rendering', + ), + ), ]; + late final _polygons = + Map.fromEntries(_polygonsRaw.map((e) => MapEntry(e.hitValue, e))); @override Widget build(BuildContext context) { @@ -83,104 +206,118 @@ class PolygonPage extends StatelessWidget { ), children: [ openStreetMapTileLayer, - PolygonLayer( - simplificationTolerance: 0, - polygons: [ - Polygon( - points: _notFilledPoints, - borderColor: Colors.red, - borderStrokeWidth: 4, - ), - Polygon( - points: _filledPoints, - color: Colors.purple, - borderColor: Colors.yellow, - borderStrokeWidth: 4, - ), - Polygon( - points: _filledDotedPoints, - isDotted: true, - borderStrokeWidth: 4, - borderColor: Colors.lightBlue, - color: Colors.yellow, - ), - Polygon( - points: _labelPoints, - borderStrokeWidth: 4, - borderColor: Colors.purple, - label: 'Label!', + MouseRegion( + hitTestBehavior: HitTestBehavior.deferToChild, + cursor: SystemMouseCursors.click, + onHover: (_) { + final hitValues = _hitNotifier.value?.hitValues.toList(); + if (hitValues == null) return; + + if (listEquals(hitValues, _prevHitValues)) return; + _prevHitValues = hitValues; + + final hoverLines = hitValues.map((v) { + final original = _polygons[v]!; + + return Polygon( + points: original.points, + holePointsList: original.holePointsList, + color: Colors.transparent, + borderStrokeWidth: 15, + borderColor: Colors.green, + disableHolesBorder: original.disableHolesBorder, + ); + }).toList(); + setState(() => _hoverGons = hoverLines); + }, + onExit: (_) { + _prevHitValues = null; + setState(() => _hoverGons = null); + }, + child: GestureDetector( + onTap: () => _openTouchedGonsModal( + 'Tapped', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.coordinate, ), - Polygon( - points: _labelRotatedPoints, - borderStrokeWidth: 4, - borderColor: Colors.purple, - label: 'Rotated!', - rotateLabel: true, - labelPlacement: PolygonLabelPlacement.polylabel, + onLongPress: () => _openTouchedGonsModal( + 'Long pressed', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.coordinate, ), - Polygon( - points: _normalHoleOuterPoints - .map((latlng) => - LatLng(latlng.latitude, latlng.longitude + 8)) - .toList(), - isDotted: true, - holePointsList: _holeInnerPoints - .map( - (latlngs) => latlngs - .map((latlng) => - LatLng(latlng.latitude, latlng.longitude + 8)) - .toList(), - ) - .toList(), - borderStrokeWidth: 4, - borderColor: Colors.orange, - color: Colors.orange.withOpacity(0.5), - label: 'This one is not\nperformantly rendered', - rotateLabel: true, - labelPlacement: PolygonLabelPlacement.centroid, - labelStyle: const TextStyle(color: Colors.black), + onSecondaryTap: () => _openTouchedGonsModal( + 'Secondary tapped', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.coordinate, ), - Polygon( - points: _brokenHoleOuterPoints - .map((latlng) => - LatLng(latlng.latitude - 6, latlng.longitude + 8)) - .toList(), - isDotted: true, - holePointsList: _holeInnerPoints - .map( - (latlngs) => latlngs - .map((latlng) => LatLng( - latlng.latitude - 6, latlng.longitude + 8)) - .toList(), - ) - .toList(), - borderStrokeWidth: 4, - borderColor: Colors.orange, - color: Colors.orange.withOpacity(0.5), - label: 'This one is not\nperformantly rendered', - rotateLabel: true, - labelPlacement: PolygonLabelPlacement.centroid, - labelStyle: const TextStyle(color: Colors.black), + child: PolygonLayer( + hitNotifier: _hitNotifier, + simplificationTolerance: 0, + polygons: [..._polygonsRaw, ...?_hoverGons], ), - ], + ), ), PolygonLayer( simplificationTolerance: 0, useAltRendering: true, polygons: [ Polygon( - points: _normalHoleOuterPoints, - holePointsList: _holeInnerPoints, + points: const [ + LatLng(50, -18), + LatLng(50, -14), + LatLng(51.5, -12.5), + LatLng(54, -14), + LatLng(54, -18), + ], + holePointsList: [ + const [ + LatLng(52, -17), + LatLng(52, -16), + LatLng(51.5, -15.5), + LatLng(51, -16), + LatLng(51, -17), + ], + const [ + LatLng(53.5, -17), + LatLng(53.5, -16), + LatLng(53, -15), + LatLng(52.25, -15), + LatLng(52.25, -16), + LatLng(52.75, -17), + ], + ], borderStrokeWidth: 4, borderColor: Colors.black, color: Colors.green, ), Polygon( - points: _brokenHoleOuterPoints + points: const [ + LatLng(50, -18), + LatLng(53, -16), + LatLng(51.5, -12.5), + LatLng(54, -14), + LatLng(54, -18), + ] .map((latlng) => LatLng(latlng.latitude - 6, latlng.longitude)) .toList(), - holePointsList: _holeInnerPoints + holePointsList: [ + const [ + LatLng(52, -17), + LatLng(52, -16), + LatLng(51.5, -15.5), + LatLng(51, -16), + LatLng(51, -17), + ], + const [ + LatLng(53.5, -17), + LatLng(53.5, -16), + LatLng(53, -15), + LatLng(52.25, -15), + LatLng(52.25, -16), + LatLng(52.75, -17), + ], + ] .map( (latlngs) => latlngs .map((latlng) => @@ -200,4 +337,59 @@ class PolygonPage extends StatelessWidget { ), ); } + + void _openTouchedGonsModal( + String eventType, + List tappedLines, + LatLng coords, + ) { + showModalBottomSheet( + context: context, + builder: (context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tapped Polygon(s)', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + Text( + '$eventType at point: (${coords.latitude.toStringAsFixed(6)}, ${coords.longitude.toStringAsFixed(6)})', + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final tappedLineData = tappedLines[index]; + return ListTile( + leading: index == 0 + ? const Icon(Icons.vertical_align_top) + : index == tappedLines.length - 1 + ? const Icon(Icons.vertical_align_bottom) + : const SizedBox.shrink(), + title: Text(tappedLineData.title), + subtitle: Text(tappedLineData.subtitle), + dense: true, + ); + }, + itemCount: tappedLines.length, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/example/lib/pages/polyline.dart b/example/lib/pages/polyline.dart index 7851459cd..a21c44625 100644 --- a/example/lib/pages/polyline.dart +++ b/example/lib/pages/polyline.dart @@ -5,7 +5,7 @@ import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; -typedef PolylineHitValue = ({String title, String subtitle}); +typedef HitValue = ({String title, String subtitle}); class PolylinePage extends StatefulWidget { static const String route = '/polyline'; @@ -17,11 +17,11 @@ class PolylinePage extends StatefulWidget { } class _PolylinePageState extends State { - final LayerHitNotifier _hitNotifier = ValueNotifier(null); - List? _prevHitValues; - List>? _hoverLines; + final LayerHitNotifier _hitNotifier = ValueNotifier(null); + List? _prevHitValues; + List>? _hoverLines; - final _polylinesRaw = >[ + final _polylinesRaw = >[ Polyline( points: [ const LatLng(51.5, -0.09), @@ -142,7 +142,7 @@ class _PolylinePageState extends State { final hoverLines = hitValues.map((v) { final original = _polylines[v]!; - return Polyline( + return Polyline( points: original.points, strokeWidth: original.strokeWidth + original.borderStrokeWidth, @@ -162,17 +162,17 @@ class _PolylinePageState extends State { onTap: () => _openTouchedLinesModal( 'Tapped', _hitNotifier.value!.hitValues, - _hitNotifier.value!.point, + _hitNotifier.value!.coordinate, ), onLongPress: () => _openTouchedLinesModal( 'Long pressed', _hitNotifier.value!.hitValues, - _hitNotifier.value!.point, + _hitNotifier.value!.coordinate, ), onSecondaryTap: () => _openTouchedLinesModal( 'Secondary tapped', _hitNotifier.value!.hitValues, - _hitNotifier.value!.point, + _hitNotifier.value!.coordinate, ), child: PolylineLayer( hitNotifier: _hitNotifier, @@ -190,7 +190,7 @@ class _PolylinePageState extends State { void _openTouchedLinesModal( String eventType, - List tappedLines, + List tappedLines, LatLng coords, ) { showModalBottomSheet( diff --git a/lib/src/layer/general/hit_detection.dart b/lib/src/layer/general/hit_detection.dart index 2e28c211e..85c0b39d7 100644 --- a/lib/src/layer/general/hit_detection.dart +++ b/lib/src/layer/general/hit_detection.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/widgets.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; @@ -11,18 +13,30 @@ class LayerHitResult { /// `hitValue`s from all features hit (which have `hitValue`s defined) /// /// If a feature is hit but has no `hitValue` defined, it will not be included. + /// May be empty. /// /// Ordered by their corresponding feature, first-to-last, visually /// top-to-bottom. final List hitValues; - /// Coordinates of the detected hit + /// Geographical coordinates of the detected hit /// /// Note that this may not lie on a feature. - final LatLng point; + /// + /// See [point] for the screen point which was hit. + final LatLng coordinate; + + /// Screen point of the detected hit + /// + /// See [coordinate] for the geographical coordinate which was hit. + final Point point; @internal - const LayerHitResult({required this.hitValues, required this.point}); + const LayerHitResult({ + required this.hitValues, + required this.coordinate, + required this.point, + }); } /// A [ValueNotifier] that notifies: diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index cf62b141c..0da257746 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -2,9 +2,9 @@ part of 'polygon_layer.dart'; /// The [_PolygonPainter] class is used to render [Polygon]s for /// the [PolygonLayer]. -class _PolygonPainter extends CustomPainter { +class _PolygonPainter extends CustomPainter { /// Reference to the list of [_ProjectedPolygon]s - final List<_ProjectedPolygon> polygons; + final List<_ProjectedPolygon> polygons; /// Triangulated [polygons] if available /// @@ -23,6 +23,11 @@ class _PolygonPainter extends CustomPainter { /// Whether to draw labels last and thus over all the polygons final bool drawLabelsLast; + /// See [PolylineLayer.hitNotifier] + final LayerHitNotifier? hitNotifier; + + final _hits = []; // Avoids repetitive memory reallocation + /// Create a new [_PolygonPainter] instance. _PolygonPainter({ required this.polygons, @@ -30,14 +35,79 @@ class _PolygonPainter extends CustomPainter { required this.camera, required this.polygonLabels, required this.drawLabelsLast, + required this.hitNotifier, }) : bounds = camera.visibleBounds; - ({Offset min, Offset max}) getBounds(Offset origin, Polygon polygon) { - final bbox = polygon.boundingBox; - return ( - min: getOffset(camera, origin, bbox.southWest), - max: getOffset(camera, origin, bbox.northEast), + @override + bool? hitTest(Offset position) { + _hits.clear(); + bool hasHit = false; + + final origin = + camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; + final point = position.toPoint(); + final coordinate = camera.pointToLatLng(point); + + for (final projectedPolygon in polygons.reversed) { + final polygon = projectedPolygon.polygon; + if ((hasHit && polygon.hitValue == null) || + !polygon.boundingBox.contains(coordinate)) { + continue; + } + + final projectedCoords = getOffsetsXY( + camera: camera, + origin: origin, + points: projectedPolygon.points, + ).toList(); + + if (projectedCoords.first != projectedCoords.last) { + projectedCoords.add(projectedCoords.first); + } + + final hasHoles = projectedPolygon.holePoints.isNotEmpty; + late final List> projectedHoleCoords; + if (hasHoles) { + projectedHoleCoords = projectedPolygon.holePoints + .map( + (points) => getOffsetsXY( + camera: camera, + origin: origin, + points: points, + ).toList(), + ) + .toList(); + + if (projectedHoleCoords.firstOrNull != projectedHoleCoords.lastOrNull) { + projectedHoleCoords.add(projectedHoleCoords.first); + } + } + + final isInPolygon = _isPointInPolygon(position, projectedCoords); + late final isInHole = hasHoles && + projectedHoleCoords + .map((c) => _isPointInPolygon(position, c)) + .any((e) => e); + + // Second check handles case where polygon outline intersects a hole, + // ensuring that the hit matches with the visual representation + if ((isInPolygon && !isInHole) || (!isInPolygon && isInHole)) { + _hits.add(polygon.hitValue!); + hasHit = true; + } + } + + if (!hasHit) { + hitNotifier?.value = null; + return false; + } + + hitNotifier?.value = LayerHitResult( + hitValues: _hits, + coordinate: coordinate, + point: point, ); + return true; } @override @@ -181,7 +251,7 @@ class _PolygonPainter extends CustomPainter { final painter = _buildLabelTextPainter( mapSize: camera.size, placementPoint: getOffset(camera, origin, polygon.labelPosition), - bounds: getBounds(origin, polygon), + bounds: _getBounds(origin, polygon), textPainter: polygon.textPainter!, rotationRad: camera.rotationRad, rotate: polygon.rotateLabel, @@ -210,7 +280,7 @@ class _PolygonPainter extends CustomPainter { final painter = _buildLabelTextPainter( mapSize: camera.size, placementPoint: getOffset(camera, origin, polygon.labelPosition), - bounds: getBounds(origin, polygon), + bounds: _getBounds(origin, polygon), textPainter: textPainter, rotationRad: camera.rotationRad, rotate: polygon.rotateLabel, @@ -306,12 +376,39 @@ class _PolygonPainter extends CustomPainter { path.addPolygon(offsets, true); } + ({Offset min, Offset max}) _getBounds(Offset origin, Polygon polygon) { + final bbox = polygon.boundingBox; + return ( + min: getOffset(camera, origin, bbox.southWest), + max: getOffset(camera, origin, bbox.northEast), + ); + } + + /// Checks whether point [p] is within the specified closed [polygon] + /// + /// Uses the even-odd algorithm. + static bool _isPointInPolygon(Offset p, List polygon) { + bool isInPolygon = false; + + for (int i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + if ((((polygon[i].dy <= p.dy) && (p.dy < polygon[j].dy)) || + ((polygon[j].dy <= p.dy) && (p.dy < polygon[i].dy))) && + (p.dx < + (polygon[j].dx - polygon[i].dx) * + (p.dy - polygon[i].dy) / + (polygon[j].dy - polygon[i].dy) + + polygon[i].dx)) isInPolygon = !isInPolygon; + } + return isInPolygon; + } + @override - bool shouldRepaint(_PolygonPainter oldDelegate) => + bool shouldRepaint(_PolygonPainter oldDelegate) => polygons != oldDelegate.polygons || triangles != oldDelegate.triangles || camera != oldDelegate.camera || bounds != oldDelegate.bounds || drawLabelsLast != oldDelegate.drawLabelsLast || - polygonLabels != oldDelegate.polygonLabels; + polygonLabels != oldDelegate.polygonLabels || + hitNotifier != oldDelegate.hitNotifier; } diff --git a/lib/src/layer/polygon_layer/polygon.dart b/lib/src/layer/polygon_layer/polygon.dart index 8e3bd0d95..b687973e2 100644 --- a/lib/src/layer/polygon_layer/polygon.dart +++ b/lib/src/layer/polygon_layer/polygon.dart @@ -10,7 +10,7 @@ enum PolygonLabelPlacement { } /// [Polygon] class, to be used for the [PolygonLayer]. -class Polygon { +class Polygon { /// The points for the outline of the [Polygon]. final List points; @@ -32,12 +32,26 @@ class Polygon { /// as dotted line. final bool isDotted; + /// **DEPRECATED** + /// + /// Prefer setting `color` to null to disable filling, or a `Color` to enable + /// filling of that color. + /// + /// This parameter will be removed to simplify the API, as this was a remnant of pre-null-safety. + /// + /// The default of this parameter is now `null` and will use the rules above - + /// the option is retained so as not to break APIs. + /// + /// This feature was deprecated (and the default changed) after v7. + /// + /// --- + /// /// Set to true if the [Polygon] should be filled with a color. @Deprecated( 'Prefer setting `color` to null to disable filling, or a `Color` to enable filling of that color. ' 'This parameter will be removed to simplify the API, as this was a remnant of pre-null-safety. ' 'The default of this parameter is now `null` and will use the rules above - the option is retained so as not to break APIs. ' - 'This feature was deprecated after v7.', + 'This feature was deprecated (and the default changed) after v7.', ) final bool? isFilled; final StrokeCap strokeCap; @@ -59,6 +73,14 @@ class Polygon { /// it remains upright final bool rotateLabel; + /// Value notified in [PolygonLayer.hitNotifier] + /// + /// Polylines without a defined [hitValue] are still hit tested, but are not + /// notified about. + /// + /// Should implement an equality operator to avoid breaking [Polygon.==]. + final R? hitValue; + /// Designates whether the given polygon points follow a clock or /// anti-clockwise direction. /// This is respected during draw call batching for filled polygons. @@ -108,7 +130,7 @@ class Polygon { 'Prefer setting `color` to null to disable filling, or a `Color` to enable filling of that color. ' 'This parameter will be removed to simplify the API, as this was a remnant of pre-null-safety. ' 'The default of this parameter is now `null` and will use the rules above - the option is retained so as not to break APIs. ' - 'This feature was deprecated after v7.', + 'This feature was deprecated (and the default changed) after v7.', ) this.isFilled, this.strokeCap = StrokeCap.round, @@ -117,6 +139,7 @@ class Polygon { this.labelStyle = const TextStyle(), this.labelPlacement = PolygonLabelPlacement.centroid, this.rotateLabel = false, + this.hitValue, }) : _filledAndClockwise = (isFilled ?? (color != null)) && isClockwise(points); @@ -148,6 +171,7 @@ class Polygon { labelStyle == other.labelStyle && labelPlacement == other.labelPlacement && rotateLabel == other.rotateLabel && + hitValue == other.hitValue && // Expensive computations last to take advantage of lazy logic gates listEquals(holePointsList, other.holePointsList) && listEquals(points, other.points)); diff --git a/lib/src/layer/polygon_layer/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index 091381789..5d309a505 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -9,7 +9,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/misc/offsets.dart'; import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart' hide Path; -import 'package:polylabel/polylabel.dart'; // conflict with Path from UI +import 'package:polylabel/polylabel.dart'; part 'label.dart'; part 'painter.dart'; @@ -18,9 +18,9 @@ part 'projected_polygon.dart'; /// A polygon layer for [FlutterMap]. @immutable -class PolygonLayer extends StatefulWidget { +class PolygonLayer extends StatefulWidget { /// [Polygon]s to draw - final List polygons; + final List> polygons; /// Whether to use an alternative rendering pathway to draw polygons onto the /// underlying `Canvas`, which can be more performant in *some* circumstances @@ -65,6 +65,17 @@ class PolygonLayer extends StatefulWidget { /// Defaults to `false`. final bool drawLabelsLast; + /// A notifier to be notified when a hit test occurs on the layer + /// + /// Notified with a [LayerHitResult] if any polylines are hit, otherwise + /// notified with `null`. + /// + /// Hit testing still occurs even if this is `null`. + /// + /// See online documentation for more detailed usage instructions. See the + /// example project for an example implementation. + final LayerHitNotifier? hitNotifier; + /// Create a new [PolygonLayer] for the [FlutterMap] widget. const PolygonLayer({ super.key, @@ -74,20 +85,21 @@ class PolygonLayer extends StatefulWidget { this.simplificationTolerance = 0.5, this.polygonLabels = true, this.drawLabelsLast = false, + this.hitNotifier, }); @override - State createState() => _PolygonLayerState(); + State> createState() => _PolygonLayerState(); } -class _PolygonLayerState extends State { - List<_ProjectedPolygon>? _cachedProjectedPolygons; - final _cachedSimplifiedPolygons = >{}; +class _PolygonLayerState extends State> { + List<_ProjectedPolygon>? _cachedProjectedPolygons; + final _cachedSimplifiedPolygons = >>{}; double? _devicePixelRatio; @override - void didUpdateWidget(PolygonLayer oldWidget) { + void didUpdateWidget(PolygonLayer oldWidget) { super.didUpdateWidget(oldWidget); if (!listEquals(oldWidget.polygons, widget.polygons)) { @@ -117,7 +129,7 @@ class _PolygonLayerState extends State { growable: false, ); - late final List<_ProjectedPolygon> simplified; + late final List<_ProjectedPolygon> simplified; if (widget.simplificationTolerance == 0) { simplified = projected; } else { @@ -182,15 +194,16 @@ class _PolygonLayerState extends State { camera: camera, polygonLabels: widget.polygonLabels, drawLabelsLast: widget.drawLabelsLast, + hitNotifier: widget.hitNotifier, ), size: Size(camera.size.x, camera.size.y), ), ); } - static List<_ProjectedPolygon> _computeZoomLevelSimplification({ + List<_ProjectedPolygon> _computeZoomLevelSimplification({ required MapCamera camera, - required List<_ProjectedPolygon> polygons, + required List<_ProjectedPolygon> polygons, required double pixelTolerance, required double devicePixelRatio, }) { @@ -201,7 +214,7 @@ class _PolygonLayerState extends State { devicePixelRatio: devicePixelRatio, ); - return List<_ProjectedPolygon>.generate( + return List<_ProjectedPolygon>.generate( polygons.length, (i) { final polygon = polygons[i]; diff --git a/lib/src/layer/polygon_layer/projected_polygon.dart b/lib/src/layer/polygon_layer/projected_polygon.dart index ec8f22508..1a462344a 100644 --- a/lib/src/layer/polygon_layer/projected_polygon.dart +++ b/lib/src/layer/polygon_layer/projected_polygon.dart @@ -1,8 +1,8 @@ part of 'polygon_layer.dart'; @immutable -class _ProjectedPolygon { - final Polygon polygon; +class _ProjectedPolygon { + final Polygon polygon; final List points; final List> holePoints; @@ -12,7 +12,7 @@ class _ProjectedPolygon { required this.holePoints, }); - _ProjectedPolygon._fromPolygon(Projection projection, Polygon polygon) + _ProjectedPolygon._fromPolygon(Projection projection, Polygon polygon) : this._( polygon: polygon, points: List.generate( diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index 9b3fe2626..65b8e1c11 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -3,7 +3,7 @@ part of 'polyline_layer.dart'; /// [CustomPainter] for [Polygon]s. class _PolylinePainter extends CustomPainter { /// Reference to the list of [Polyline]s. - final List<_ProjectedPolyline> polylines; + final List<_ProjectedPolyline> polylines; /// Reference to the [MapCamera]. final MapCamera camera; @@ -22,23 +22,20 @@ class _PolylinePainter extends CustomPainter { @override bool? hitTest(Offset position) { - if (hitNotifier == null) return null; - _hits.clear(); + bool hasHit = false; final origin = camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; for (final projectedPolyline in polylines.reversed) { - final polyline = projectedPolyline.polyline as Polyline; - if (polyline.hitValue == null) { - continue; - } + final polyline = projectedPolyline.polyline; + if (hasHit && polyline.hitValue == null) continue; // TODO: For efficiency we'd ideally filter by bounding box here. However // we'd need to compute an extended bounding box that accounts account for - // the stroke width. - // if (!p.boundingBox.contains(touch)) { + // the `borderStrokeWidth` & the `minimumHitbox` + // if (!polyline.boundingBox.contains(touch)) { // continue; // } @@ -64,32 +61,27 @@ class _PolylinePainter extends CustomPainter { final o1 = offsets[i]; final o2 = offsets[i + 1]; - final distance = math.sqrt( - getSqSegDist( - position.dx, - position.dy, - o1.dx, - o1.dy, - o2.dx, - o2.dy, - ), - ); + final distanceSq = + getSqSegDist(position.dx, position.dy, o1.dx, o1.dy, o2.dx, o2.dy); - if (distance < hittableDistance) { - _hits.add(polyline.hitValue!); + if (distanceSq <= hittableDistance * hittableDistance) { + if (polyline.hitValue != null) _hits.add(polyline.hitValue!); + hasHit = true; break; } } } - if (_hits.isEmpty) { - hitNotifier!.value = null; + if (!hasHit) { + hitNotifier?.value = null; return false; } - hitNotifier!.value = LayerHitResult( + final point = position.toPoint(); + hitNotifier?.value = LayerHitResult( hitValues: _hits, - point: camera.pointToLatLng(math.Point(position.dx, position.dy)), + coordinate: camera.pointToLatLng(point), + point: point, ); return true; } diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index 6be75f675..d56a55e6f 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -40,11 +40,11 @@ class PolylineLayer extends StatefulWidget { /// A notifier to be notified when a hit test occurs on the layer /// - /// If a notifier is not provided, hit testing is not performed. - /// /// Notified with a [LayerHitResult] if any polylines are hit, otherwise /// notified with `null`. /// + /// Hit testing still occurs even if this is `null`. + /// /// See online documentation for more detailed usage instructions. See the /// example project for an example implementation. final LayerHitNotifier? hitNotifier; @@ -151,13 +151,13 @@ class _PolylineLayerState extends State> { ); } - static List<_ProjectedPolyline> _aggressivelyCullPolylines({ + List<_ProjectedPolyline> _aggressivelyCullPolylines({ required Projection projection, - required List<_ProjectedPolyline> polylines, + required List<_ProjectedPolyline> polylines, required MapCamera camera, required double cullingMargin, }) { - final culledPolylines = <_ProjectedPolyline>[]; + final culledPolylines = <_ProjectedPolyline>[]; final bounds = camera.visibleBounds; final margin = cullingMargin / math.pow(2, camera.zoom); @@ -237,8 +237,7 @@ class _PolylineLayerState extends State> { return culledPolylines; } - static List<_ProjectedPolyline> - _computeZoomLevelSimplification({ + List<_ProjectedPolyline> _computeZoomLevelSimplification({ required MapCamera camera, required List<_ProjectedPolyline> polylines, required double pixelTolerance, diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index 733d51911..302dfd00d 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -130,6 +132,23 @@ class MapOptions { /// widget from rebuilding. final bool keepAlive; + /// **DEPRECATED** + /// + /// If necessary, manually wrap layers with `TransulcentPointer` widgets. + /// + /// This parameter will be removed as proper hit detection has now been + /// incorporated into both `PolygonLayer` & `PolylineLayer`, which reduces the + /// need for this workaround, and because it caused issues in some cases. More + /// information about hit detection & interactivity rules can be found in the + /// online documentation. + /// + /// The default of this parameter is now `false` and will use the rules above; + /// the option is retained so as not to break APIs. + /// + /// This feature was deprecated (and the default changed) after v7. + /// + /// --- + /// /// Whether to apply pointer translucency to all layers automatically /// /// This will mean that each layer can handle all the gestures that enter the @@ -142,8 +161,14 @@ class MapOptions { /// testing (and thus `Stack` will keep bubbling gestures down all layers), but /// will still allow their subtree to receive pointer events. /// - /// If this is `false` (defaults to `true`), then [TranslucentPointer] may be + /// If this is `false` (defaults to `false`), then [TranslucentPointer] may be /// manually applied to individual layers. + @Deprecated( + 'If necessary, manually wrap layers with `TransulcentPointer` widgets. ' + 'This parameter will be removed as proper hit detection has now been incorporated into both `PolygonLayer` & `PolylineLayer`, which reduces the need for this workaround, and because it caused issues in some cases. More information about hit detection & interactivity rules can be found in the online documentation. ' + 'The default of this parameter is now `false` and will use the rules above - the option is retained so as not to break APIs. ' + 'This feature was deprecated (and the default changed) after v7.', + ) final bool applyPointerTranslucencyToLayers; /// Gesture and input options for the map widget. @@ -172,7 +197,13 @@ class MapOptions { this.onMapEvent, this.onMapReady, this.keepAlive = false, - this.applyPointerTranslucencyToLayers = true, + @Deprecated( + 'If necessary, manually wrap layers with `TransulcentPointer` widgets. ' + 'This parameter will be removed as proper hit detection has now been incorporated into both `PolygonLayer` & `PolylineLayer`, which reduces the need for this workaround, and because it caused issues in some cases. More information about hit detection & interactivity rules can be found in the online documentation. ' + 'The default of this parameter is now `false` and will use the rules above - the option is retained so as not to break APIs. ' + 'This feature was deprecated (and the default changed) after v7.', + ) + this.applyPointerTranslucencyToLayers = false, }); /// The options of the closest [FlutterMap] ancestor. If this is called from a diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index 55c5e7cfb..a17e58e4a 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -31,9 +31,6 @@ class FlutterMap extends StatefulWidget { /// [Align] or another method to position themselves). Widgets/layers may or /// may not identify which type they are in their documentation, but it should /// be relatively self-explanatory from their purpose. - /// - /// [TranslucentPointer] will be wrapped around each child by default, unless - /// [MapOptions.applyPointerTranslucencyToLayers] is `false`. final List children; /// Configure this map's permanent rules and initial state @@ -103,12 +100,10 @@ class _FlutterMapStateContainer extends State Positioned.fill( child: ColoredBox(color: widget.options.backgroundColor), ), - ...widget.children.map( - (child) => TranslucentPointer( - translucent: widget.options.applyPointerTranslucencyToLayers, - child: child, - ), - ), + // ignore: deprecated_member_use_from_same_package + ...widget.options.applyPointerTranslucencyToLayers + ? widget.children.map((child) => TranslucentPointer(child: child)) + : widget.children, ], ), );