diff --git a/example/lib/misc/test_marker.dart b/example/lib/misc/test_marker.dart new file mode 100644 index 000000000..61b496194 --- /dev/null +++ b/example/lib/misc/test_marker.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +final _randomGenerator = Random(10); + +class TestMarker extends StatefulWidget { + const TestMarker({super.key, required this.point}); + + final LatLng point; + + @override + State createState() => _TestMarkerState(); +} + +class _TestMarkerState extends State { + Timer? _tapSelected; + bool _hoverSelected = false; + + bool get _isSelected => _tapSelected != null || _hoverSelected; + + late final _unselectedColor = Color.fromARGB( + 255, + _randomGenerator.nextInt(256), + _randomGenerator.nextInt(256), + _randomGenerator.nextInt(256), + ); + + @override + Widget build(BuildContext context) { + print('built ${widget.point}'); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: _isSelected ? Colors.green : Colors.black.withAlpha(51), + width: _isSelected ? 3 : 1, + ), + ), + child: MouseRegion( + onEnter: (_) => setState(() => _hoverSelected = true), + onExit: (_) => setState(() => _hoverSelected = false), + child: GestureDetector( + onTap: () => setState(() { + _tapSelected?.cancel(); + _tapSelected = Timer( + const Duration(seconds: 1), + () { + if (mounted) setState(() => _tapSelected = null); + }, + ); + }), + child: Icon( + Icons.location_pin, + size: 40, + color: _isSelected ? Colors.green : _unselectedColor, + ), + ), + ), + ); + } +} diff --git a/example/lib/pages/many_markers.dart b/example/lib/pages/many_markers.dart index f8060e785..3cfd9ff6b 100644 --- a/example/lib/pages/many_markers.dart +++ b/example/lib/pages/many_markers.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/test_marker.dart'; import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:flutter_map_example/widgets/number_of_items_slider.dart'; @@ -39,37 +40,19 @@ class ManyMarkersPageState extends State { distance * sin(angle) * (0.7 + randomGenerator.nextDouble() * 0.6); final lngOffset = distance * cos(angle) * (0.7 + randomGenerator.nextDouble() * 0.6); - final position = LatLng( + final point = LatLng( _londonOrigin.latitude + latOffset, _londonOrigin.longitude + lngOffset, ); return Marker( - point: position, - width: 30, - height: 30, - child: GestureDetector( - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Tapped existing marker (${position.latitude}, ' - '${position.longitude})', - ), - duration: const Duration(seconds: 1), - showCloseIcon: true, - ), - ), - child: Icon( - Icons.location_pin, - size: 30, - color: Color.fromARGB( - 255, - randomGenerator.nextInt(256), - randomGenerator.nextInt(256), - randomGenerator.nextInt(256), - ), - ), - ), + point: point, + // TODO: Part of what's being tested is removing these w/h. But we can't + // perform culling before build without - but maybe that's not too + // necessary now? + // width: 30, + // height: 30, + child: TestMarker(point: point), ); }, ); @@ -100,6 +83,7 @@ class ManyMarkersPageState extends State { children: [ openStreetMapTileLayer, MarkerLayer( + alignment: Alignment.topCenter, markers: allMarkers .take(displayedMarkersCount) .toList(growable: false), diff --git a/example/lib/pages/markers.dart b/example/lib/pages/markers.dart index 2f652096b..6dff6d701 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/test_marker.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'; @@ -30,24 +31,18 @@ class _MarkerPageState extends State { }; late final customMarkers = [ - buildPin(const LatLng(51.51868093513547, -0.12835376940892318)), - buildPin(const LatLng(53.33360293799854, -6.284001062079881)), + buildPin(const LatLng(51.5186809, -0.1283537)), + buildPin(const LatLng(53.3336029, -6.2840010)), ]; Marker buildPin(LatLng point) => Marker( point: point, - 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), - ), + // TODO: Part of what's being tested is removing these w/h. But we can't + // perform culling before build without - but maybe that's not too + // necessary now? + // width: 60, + // height: 60, + child: TestMarker(point: point), ); @override @@ -128,37 +123,46 @@ class _MarkerPageState extends State { openStreetMapTileLayer, MarkerLayer( rotate: counterRotate, - markers: const [ + markers: [ Marker( point: LatLng(47.18664724067855, -1.5436768515939427), - width: 64, - height: 64, + //width: 64, + //height: 64, alignment: Alignment.centerLeft, - child: ColoredBox( - color: Colors.lightBlue, - child: Align( - alignment: Alignment.centerRight, - child: Text('-->'), + child: SizedBox.square( + dimension: 64, + child: ColoredBox( + color: Colors.lightBlue, + child: Align( + alignment: Alignment.centerRight, + child: Text('-->'), + ), ), ), ), Marker( point: LatLng(47.18664724067855, -1.5436768515939427), - width: 64, - height: 64, + //width: 64, + //height: 64, alignment: Alignment.centerRight, - child: ColoredBox( - color: Colors.pink, - child: Align( - alignment: Alignment.centerLeft, - child: Text('<--'), + child: SizedBox.square( + dimension: 64, + child: ColoredBox( + color: Colors.pink, + child: Align( + alignment: Alignment.centerLeft, + child: Text('<--'), + ), ), ), ), Marker( point: LatLng(47.18664724067855, -1.5436768515939427), rotate: false, - child: ColoredBox(color: Colors.black), + child: SizedBox.square( + dimension: 24, + child: ColoredBox(color: Colors.black), + ), ), ], ), diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index fe4693d46..c130674a1 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -1,12 +1,15 @@ +import 'package:flutter/rendering.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:latlong2/latlong.dart'; part 'marker.dart'; -/// A [Marker] layer for [FlutterMap]. +/// A layer for [FlutterMap] which displays custom widgets at specified +/// coordinates ([Marker]s). @immutable -class MarkerLayer extends StatelessWidget { +class MarkerLayer extends MultiChildRenderObjectWidget { /// The list of [Marker]s. final List markers; @@ -38,88 +41,311 @@ class MarkerLayer extends StatelessWidget { this.rotate = false, }); + // TODO: Consider whether culling before build is still necessary @override - Widget build(BuildContext context) { - final map = MapCamera.of(context); - final worldWidth = map.getWorldWidthAtZoom(); - - return MobileLayerTransformer( - child: Stack( - children: (List markers) sync* { - for (final m in markers) { - // Resolve real alignment - // TODO: maybe just using Size, Offset, and Rect? - final left = 0.5 * m.width * ((m.alignment ?? alignment).x + 1); - final top = 0.5 * m.height * ((m.alignment ?? alignment).y + 1); - final right = m.width - left; - final bottom = m.height - top; - - // Perform projection - final pxPoint = map.projectAtZoom(m.point); - - Positioned? getPositioned(double worldShift) { - final shiftedX = pxPoint.dx + worldShift; - - // Cull if out of bounds - if (!map.pixelBounds.overlaps( - Rect.fromPoints( - Offset(shiftedX + left, pxPoint.dy - bottom), - Offset(shiftedX - right, pxPoint.dy + top), - ), - )) { - return null; - } - - // Shift original coordinate along worlds, then move into relative - // to origin space - final shiftedLocalPoint = - Offset(shiftedX, pxPoint.dy) - map.pixelOrigin; - - return Positioned( - key: m.key, - width: m.width, - height: m.height, - left: shiftedLocalPoint.dx - right, - top: shiftedLocalPoint.dy - bottom, - child: (m.rotate ?? rotate) - ? Transform.rotate( - angle: -map.rotationRad, - alignment: (m.alignment ?? alignment) * -1, - child: m.child, - ) - : m.child, - ); - } + List get children => + markers.map((m) => _MarkerWidget(marker: m)).toList(growable: false); - // Create marker in main world, unless culled - final main = getPositioned(0); - if (main != null) yield main; - // It is unsafe to assume that if the main one is culled, it will - // also be culled in all other worlds, so we must continue - - // TODO: optimization - find a way to skip these tests in some - // obvious situations. Imagine we're in a map smaller than the - // world, and west lower than east - in that case we probably don't - // need to check eastern and western. - - // Repeat over all worlds (<--||-->) until culling determines that - // that marker is out of view, and therefore all further markers in - // that direction will also be - if (worldWidth == 0) continue; - for (double shift = -worldWidth;; shift -= worldWidth) { - final additional = getPositioned(shift); - if (additional == null) break; - yield additional; - } - for (double shift = worldWidth;; shift += worldWidth) { - final additional = getPositioned(shift); - if (additional == null) break; - yield additional; + @override + RenderObject createRenderObject(BuildContext context) => + _MarkerLayerRenderBox( + camera: MapCamera.of(context), + alignment: alignment, + rotate: rotate, + ); + + @override + void updateRenderObject( + BuildContext context, + // ignore: library_private_types_in_public_api + covariant _MarkerLayerRenderBox renderObject, + ) { + final latestCamera = MapCamera.of(context); + if (latestCamera != renderObject.camera) { + renderObject.camera = latestCamera; + } + + if (alignment != renderObject.alignment) { + renderObject.alignment = alignment; + } + + if (rotate != renderObject.rotate) { + renderObject.rotate = rotate; + } + } +} + +class _MarkerWidget extends ParentDataWidget<_MarkerParentData> { + _MarkerWidget({required this.marker}) : super(child: marker.child); + + final Marker marker; + + // TODO: I think? this means the `Marker` must be constructed `const` or have + // a key set? + @override + Key? get key => marker.key ?? ObjectKey(marker); + + @override + void applyParentData(RenderObject renderObject) { + final parentData = renderObject.parentData! as _MarkerParentData; + bool needsLayout = false; + + if (parentData.point != marker.point) { + parentData.point = marker.point; + needsLayout = true; + } + if (parentData.alignment != marker.alignment) { + parentData.alignment = marker.alignment; + needsLayout = true; + } + if (parentData.rotate != marker.rotate) { + parentData.rotate = marker.rotate; + needsLayout = true; + } + + if (needsLayout) renderObject.parent?.markNeedsLayout(); + } + + @override + Type get debugTypicalAncestorWidgetClass => MarkerLayer; +} + +class _MarkerParentData extends ParentData + with ContainerParentDataMixin { + LatLng? point; + Alignment? alignment; + bool? rotate; +} + +/// See also [RenderStack] & [RenderTransform] +class _MarkerLayerRenderBox extends RenderBox + with ContainerRenderObjectMixin { + _MarkerLayerRenderBox({ + required MapCamera camera, + required Alignment alignment, + required bool rotate, + }) : _camera = camera, + _alignment = alignment, + _rotate = rotate; + + MapCamera get camera => _camera; + MapCamera _camera; + set camera(MapCamera value) { + if (value == _camera) return; + _camera = value; + markNeedsPaint(); + } + + Alignment get alignment => _alignment; + Alignment _alignment; + set alignment(Alignment value) { + if (value == _alignment) return; + _alignment = value; + markNeedsPaint(); + } + + bool get rotate => _rotate; + bool _rotate; + set rotate(bool value) { + if (value == _rotate) return; + _rotate = value; + markNeedsPaint(); + } + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! _MarkerParentData) { + child.parentData = _MarkerParentData(); + } + } + + @override + void performLayout() { + size = constraints.biggest; + + var child = firstChild; + while (child != null) { + child.layout(const BoxConstraints(), parentUsesSize: true); + child = (child.parentData! as _MarkerParentData).nextSibling; + } + } + +// TODO: This is in `RenderTransform`, but I can't figure out what it +// necessarily does or whether we can take advantage of it +/* + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.multiply(_effectiveTransform); + } +*/ + + ({Offset markerOffset, Offset alignmentOffset}) _getChildOffsets( + _MarkerParentData childParentData, + Size childSize, + ) => + ( + markerOffset: camera.latLngToScreenOffset(childParentData.point!), + alignmentOffset: + ((childParentData.alignment ?? alignment) * -1).alongSize(childSize) + ); + + bool _isChildInvisible(Offset shiftedChildOffset, Size childSize) => + size.width <= shiftedChildOffset.dx || + shiftedChildOffset.dx + childSize.width <= 0 || + size.height <= shiftedChildOffset.dy || + shiftedChildOffset.dy + childSize.height <= 0; + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + var child = lastChild; + while (child != null) { + final childParentData = child.parentData! as _MarkerParentData; + + final (:markerOffset, :alignmentOffset) = + _getChildOffsets(childParentData, child.size); + + if (childParentData.rotate ?? rotate + ? + // TODO: Repeat across worlds + result.addWithPaintTransform( + transform: Matrix4.identity() + ..leftTranslateByDouble(markerOffset.dx, markerOffset.dy, 0, 1) + ..rotateZ(camera.rotationRad), + position: position, + hitTest: (result, transformed) { + return child! + .hitTest(result, position: transformed + alignmentOffset); + }, + ) + : + // TODO: Fix issue of not rotating across worlds + result.addWithPaintOffset( + offset: markerOffset - alignmentOffset, + position: position, + hitTest: (result, transformed) => _workAcrossWorlds( + camera, + (shift) { + final childOffset = + Offset(transformed.dx + shift, transformed.dy); + + if (_isChildInvisible(childOffset, child!.size)) { + return WorldWorkControl.invisible; + } + + return child.hitTest(result, position: childOffset) + ? WorldWorkControl.hit + : WorldWorkControl.visible; + }, + ), + )) { + return true; + } + + child = childParentData.previousSibling; + } + return false; + } + + @override + void paint(PaintingContext context, Offset offset) { + var child = firstChild; + while (child != null) { + final childParentData = child.parentData! as _MarkerParentData; + + final (:markerOffset, :alignmentOffset) = + _getChildOffsets(childParentData, child.size); + + if (childParentData.rotate ?? rotate) { + // TODO: Repeat across worlds + layer = context.pushTransform( + needsCompositing, + offset + markerOffset, + Matrix4.identity()..rotateZ(camera.rotationRad), + (context, offset) => + context.paintChild(child!, offset - alignmentOffset), + oldLayer: layer is TransformLayer ? layer as TransformLayer? : null, + ); + } else { + // TODO: Fix issue of not rotating across worlds + final unshiftedChildOffset = offset + markerOffset - alignmentOffset; + + _workAcrossWorlds( + camera, + (shift) { + final childOffset = Offset( + unshiftedChildOffset.dx + shift, + unshiftedChildOffset.dy, + ); + + if (_isChildInvisible(childOffset, child!.size)) { + return WorldWorkControl.invisible; } - } - }(markers) - .toList(), - ), - ); + + context.paintChild(child, childOffset); + return WorldWorkControl.visible; + }, + ); + } + + child = childParentData.nextSibling; + } + } +} + +/// Perform the callback in all world copies (until stopped) +/// +/// See [WorldWorkControl] for information about the callback return types. +/// Returns `true` if any result is [WorldWorkControl.hit]. +/// +/// Internally, the worker is invoked in the 'negative' worlds (worlds to the +/// left of the 'primary' world) until repetition is stopped, then in the +/// 'positive' worlds: <--||-->. +// TODO: Remove duplication - consider how to refactor `FeatureLayerUtils` +bool _workAcrossWorlds( + MapCamera camera, + WorldWorkControl Function(double shift) work, +) { + // Protection in case of unexpected infinite loop if `work` never returns + // `invisible`. e.g. https://github.com/fleaflet/flutter_map/issues/2052. + //! This can produce false positives - but it's better than a crash. + const maxShiftsCount = 30; + int shiftsCount = 0; + + final worldWidth = camera.getWorldWidthAtZoom(); + + void protectInfiniteLoop() { + if (++shiftsCount > maxShiftsCount) { + throw AssertionError( + 'Infinite loop going beyond $maxShiftsCount for world width $worldWidth', + ); + } + } + + protectInfiniteLoop(); + if (work(0) == WorldWorkControl.hit) return true; + + if (worldWidth == 0) return false; + + negativeWorldsLoop: + for (double shift = -worldWidth;; shift -= worldWidth) { + protectInfiniteLoop(); + switch (work(shift)) { + case WorldWorkControl.hit: + return true; + case WorldWorkControl.invisible: + break negativeWorldsLoop; + case WorldWorkControl.visible: + } + } + + for (double shift = worldWidth;; shift += worldWidth) { + protectInfiniteLoop(); + switch (work(shift)) { + case WorldWorkControl.hit: + return true; + case WorldWorkControl.invisible: + return false; + case WorldWorkControl.visible: + } } } diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index a6a51e8a8..f4c1e40b0 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -332,9 +332,8 @@ class MapCamera { var point = crs.latLngToOffset(latLng, zoom); - final mapCenter = crs.latLngToOffset(center, zoom); - if (rotation != 0.0) { + final mapCenter = crs.latLngToOffset(center, zoom); point = rotatePoint(mapCenter, point, counterRotation: false); }