From 0c65540721dc8d59c6d1b3e0343d3268f975fff4 Mon Sep 17 00:00:00 2001 From: Joscha <34318751+josxha@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:25:42 +0100 Subject: [PATCH 1/3] misc changes - merge `ui` example with `styled-map` - don't use parameters that are null for animateCamera and moveCamera - add removePinchOnPressed to MapCompass - refactor --- example/lib/main.dart | 2 -- example/lib/menu_page.dart | 6 ---- example/lib/styled_map_page.dart | 7 +++- example/lib/user_interface_page.dart | 39 --------------------- lib/src/map.dart | 6 ++++ lib/src/platform/web/interop/camera.dart | 17 +++++++++ lib/src/platform/web/interop/map.dart | 17 --------- lib/src/platform/web/map_state.dart | 44 +++++++++++++----------- lib/src/ui/map_compass.dart | 32 ++++++++++++----- lib/src/ui/map_control_buttons.dart | 3 +- 10 files changed, 77 insertions(+), 96 deletions(-) delete mode 100644 example/lib/user_interface_page.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 7c8c4a02..bda4311a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -23,7 +23,6 @@ import 'package:maplibre_example/style_layers_raster_page.dart'; import 'package:maplibre_example/style_layers_symbol_page.dart'; import 'package:maplibre_example/styled_map_page.dart'; import 'package:maplibre_example/two_maps_page.dart'; -import 'package:maplibre_example/user_interface_page.dart'; import 'package:maplibre_example/user_location_page.dart'; import 'package:maplibre_example/widget_layer_page.dart'; @@ -52,7 +51,6 @@ class MyApp extends StatelessWidget { EventsPage.location: (context) => const EventsPage(), StyledMapPage.location: (context) => const StyledMapPage(), UserLocationPage.location: (context) => const UserLocationPage(), - UserInterfacePage.location: (context) => const UserInterfacePage(), WidgetLayerPage.location: (context) => const WidgetLayerPage(), OfflinePage.location: (context) => const OfflinePage(), PermissionsPage.location: (context) => const PermissionsPage(), diff --git a/example/lib/menu_page.dart b/example/lib/menu_page.dart index 6748fde2..738c7a12 100644 --- a/example/lib/menu_page.dart +++ b/example/lib/menu_page.dart @@ -22,7 +22,6 @@ import 'package:maplibre_example/style_layers_raster_page.dart'; import 'package:maplibre_example/style_layers_symbol_page.dart'; import 'package:maplibre_example/styled_map_page.dart'; import 'package:maplibre_example/two_maps_page.dart'; -import 'package:maplibre_example/user_interface_page.dart'; import 'package:maplibre_example/user_location_page.dart'; import 'package:maplibre_example/widget_layer_page.dart'; @@ -83,11 +82,6 @@ class MenuPage extends StatelessWidget { iconData: Icons.gps_fixed, location: UserLocationPage.location, ), - ItemCard( - label: 'User interface', - iconData: Icons.control_camera, - location: UserInterfacePage.location, - ), if (!kIsWeb) ItemCard( label: 'Offline', diff --git a/example/lib/styled_map_page.dart b/example/lib/styled_map_page.dart index 8e525626..de963322 100644 --- a/example/lib/styled_map_page.dart +++ b/example/lib/styled_map_page.dart @@ -23,7 +23,12 @@ class _StyledMapPageState extends State { initZoom: 2, initStyle: MapStyles.maptilerStreets, ), - children: const [SourceAttribution()], + children: const [ + MapScalebar(), + SourceAttribution(), + MapControlButtons(showTrackLocation: true), + MapCompass(), + ], onStyleLoaded: (style) { style.setProjection(MapProjection.globe); }, diff --git a/example/lib/user_interface_page.dart b/example/lib/user_interface_page.dart deleted file mode 100644 index ffacfe83..00000000 --- a/example/lib/user_interface_page.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:maplibre/maplibre.dart'; -import 'package:maplibre_example/map_styles.dart'; - -@immutable -class UserInterfacePage extends StatefulWidget { - const UserInterfacePage({super.key}); - - static const location = '/ui'; - - @override - State createState() => _UserInterfacePageState(); -} - -class _UserInterfacePageState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('User Interface')), - body: MapLibreMap( - options: MapOptions( - initZoom: 3, - initCenter: Position(9.17, 47.68), - initStyle: Theme.of(context).brightness == Brightness.light - ? MapStyles.protomapsLight - : MapStyles.protomapsDark, - ), - children: const [ - MapScalebar(), - SourceAttribution(), - MapControlButtons( - showTrackLocation: true, - ), - MapCompass(), - ], - ), - ); - } -} diff --git a/lib/src/map.dart b/lib/src/map.dart index 6e60845f..705a54b1 100644 --- a/lib/src/map.dart +++ b/lib/src/map.dart @@ -27,6 +27,12 @@ class MapLibreMap extends StatefulWidget { /// Flutter widgets that get displayed on top on the map and are within the /// [MapLibreMap] context. + /// + /// You can use the following included UI elements: + /// - [MapCompass] + /// - [MapControlButtons] + /// - [MapScalebar] + /// - [SourceAttribution]. final List children; /// Which gestures should be consumed by the map. diff --git a/lib/src/platform/web/interop/camera.dart b/lib/src/platform/web/interop/camera.dart index 6921b5b0..fa2ce21a 100644 --- a/lib/src/platform/web/interop/camera.dart +++ b/lib/src/platform/web/interop/camera.dart @@ -60,6 +60,23 @@ extension type FlyToOptions._(CameraOptions _) implements CameraOptions { }); } +/// Options to specify the map bounds. +@anonymous +@JS() +extension type FitBoundsOptions._(FlyToOptions _) implements FlyToOptions { + /// Create a new JS [FitBoundsOptions] object. + external FitBoundsOptions({ + bool? linear, + Point? offset, + num? maxZoom, + num? maxDuration, + PaddingOptions? padding, + num? speed, + num? pitch, + num? bearing, + }); +} + /// https://github.com/maplibre/maplibre-gl-js/blob/8a005bd7d4769b62db470ce7e8cf2b08255c1d36/src/geo/edge_insets.ts#L129 @anonymous @JS() diff --git a/lib/src/platform/web/interop/map.dart b/lib/src/platform/web/interop/map.dart index 66853890..84cdfb64 100644 --- a/lib/src/platform/web/interop/map.dart +++ b/lib/src/platform/web/interop/map.dart @@ -136,23 +136,6 @@ extension type MapOptions._(JSObject _) implements JSObject { }); } -/// Options to specify the map bounds. -@anonymous -@JS() -extension type FitBoundsOptions._(FlyToOptions _) implements FlyToOptions { - /// Create a new JS [FitBoundsOptions] object. - external FitBoundsOptions({ - bool? linear, - Point? offset, - num? maxZoom, - num? maxDuration, - PaddingOptions? padding, - num? speed, - num? pitch, - num? bearing, - }); -} - /// The specifications of map sources. @anonymous @JS() diff --git a/lib/src/platform/web/map_state.dart b/lib/src/platform/web/map_state.dart index b2bcdded..7b05f437 100644 --- a/lib/src/platform/web/map_state.dart +++ b/lib/src/platform/web/map_state.dart @@ -243,12 +243,13 @@ final class MapLibreMapStateWeb extends MapLibreMapState { double? pitch, }) async { _nextGestureCausedByController = true; + final camera = getCamera(); _map.jumpTo( interop.JumpToOptions( center: center?.toLngLat(), - zoom: zoom, - bearing: bearing, - pitch: pitch, + zoom: zoom ?? camera.zoom, + bearing: bearing ?? camera.bearing, + pitch: pitch ?? camera.pitch, ), ); } @@ -265,12 +266,13 @@ final class MapLibreMapStateWeb extends MapLibreMapState { }) async { final destination = center?.toLngLat(); _nextGestureCausedByController = true; + final camera = getCamera(); _map.flyTo( interop.FlyToOptions( center: destination, - zoom: zoom, - bearing: bearing, - pitch: pitch, + zoom: zoom ?? camera.zoom, + bearing: bearing ?? camera.bearing, + pitch: pitch ?? camera.pitch, speed: webSpeed, maxDuration: webMaxDuration?.inMilliseconds, ), @@ -313,20 +315,22 @@ final class MapLibreMapStateWeb extends MapLibreMapState { double webMaxZoom = double.maxFinite, bool webLinear = false, EdgeInsets padding = EdgeInsets.zero, - }) async => - _map.fitBounds( - bounds.toJsLngLatBounds(), - interop.FitBoundsOptions( - offset: offset.toJsPoint(), - maxZoom: webMaxZoom, - linear: webLinear, - maxDuration: webMaxDuration?.inMilliseconds, - padding: padding.toPaddingOptions(), - speed: webSpeed, - pitch: pitch, - bearing: bearing, - ), - ); + }) async { + final camera = getCamera(); + _map.fitBounds( + bounds.toJsLngLatBounds(), + interop.FitBoundsOptions( + offset: offset.toJsPoint(), + maxZoom: webMaxZoom, + linear: webLinear, + maxDuration: webMaxDuration?.inMilliseconds, + padding: padding.toPaddingOptions(), + speed: webSpeed, + pitch: pitch ?? camera.pitch, + bearing: bearing ?? camera.bearing, + ), + ); + } @override MapCamera getCamera() => MapCamera( diff --git a/lib/src/ui/map_compass.dart b/lib/src/ui/map_compass.dart index 53b8ccd8..7e90fabd 100644 --- a/lib/src/ui/map_compass.dart +++ b/lib/src/ui/map_compass.dart @@ -16,6 +16,8 @@ class MapCompass extends StatelessWidget { super.key, this.rotationOffset = 0, this.rotationDuration = const Duration(milliseconds: 200), + this.webRotationSpeed = 1.2, + this.removePinchOnPressed = false, this.rotateNorthOnPressed = true, this.onPressed, this.hideIfRotatedNorth = false, @@ -40,6 +42,11 @@ class MapCompass extends StatelessWidget { /// Defaults to true. final bool rotateNorthOnPressed; + /// Reset the camera pinch / tilt back to 0 when clicked. + /// + /// Defaults to false. + final bool removePinchOnPressed; + /// Set to true to hide the compass while the map is not rotated. /// /// Defaults to false (always visible). @@ -60,6 +67,9 @@ class MapCompass extends StatelessWidget { /// Default to 200 ms. final Duration rotationDuration; + /// The speed of the rotation animation on web. + final double webRotationSpeed; + /// The compass radius. final double radius; @@ -80,15 +90,7 @@ class MapCompass extends StatelessWidget { angle: (-camera.bearing + rotationOffset) * degree2Radian, child: PointerInterceptor( child: InkWell( - onTap: () { - if (rotateNorthOnPressed) { - controller.animateCamera( - bearing: 0, - nativeDuration: rotationDuration, - ); - } - onPressed?.call(); - }, + onTap: () => _onTap(controller), child: child ?? CustomPaint( painter: _CompassPainter(radius: radius), @@ -99,6 +101,18 @@ class MapCompass extends StatelessWidget { ), ); } + + void _onTap(MapController controller) { + if (rotateNorthOnPressed || removePinchOnPressed) { + controller.animateCamera( + bearing: rotateNorthOnPressed ? 0 : null, + pitch: removePinchOnPressed ? 0 : null, + nativeDuration: rotationDuration, + webSpeed: webRotationSpeed, + ); + } + onPressed?.call(); + } } class _CompassPainter extends CustomPainter { diff --git a/lib/src/ui/map_control_buttons.dart b/lib/src/ui/map_control_buttons.dart index ab17b75b..d84e0390 100644 --- a/lib/src/ui/map_control_buttons.dart +++ b/lib/src/ui/map_control_buttons.dart @@ -77,6 +77,7 @@ class _MapControlButtonsState extends State { padding: widget.padding, child: PointerInterceptor( child: Column( + spacing: 8, mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton( @@ -87,7 +88,6 @@ class _MapControlButtonsState extends State { ), child: const Icon(Icons.add), ), - const SizedBox(height: 8), FloatingActionButton( heroTag: 'MapLibreZoomOutButton', onPressed: () => controller.animateCamera( @@ -97,7 +97,6 @@ class _MapControlButtonsState extends State { child: const Icon(Icons.remove), ), if (!kIsWeb && widget.showTrackLocation) ...[ - const SizedBox(height: 8), FloatingActionButton( heroTag: 'MapLibreTrackLocationButton', onPressed: () async => _initializeLocation(controller), From 1395b3d07ac6e3671a42351120dba5de68ba83fc Mon Sep 17 00:00:00 2001 From: Joscha <34318751+josxha@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:52:39 +0100 Subject: [PATCH 2/3] underline attributions on hover, add max width --- lib/src/ui/source_attribution.dart | 94 +++++++++++++++++------------- 1 file changed, 54 insertions(+), 40 deletions(-) diff --git a/lib/src/ui/source_attribution.dart b/lib/src/ui/source_attribution.dart index 061b059d..2e2c8c99 100644 --- a/lib/src/ui/source_attribution.dart +++ b/lib/src/ui/source_attribution.dart @@ -59,6 +59,8 @@ class _SourceAttributionState extends State { } final theme = Theme.of(context); + final size = MediaQuery.sizeOf(context); + return FutureBuilder>( future: style.getAttributions(), initialData: const [], @@ -67,7 +69,11 @@ class _SourceAttributionState extends State { debugPrint('SourceAttribution error: ${snapshot.error}'); debugPrintStack(stackTrace: snapshot.stackTrace); } - final attributions = snapshot.data ?? const []; + final attributions = [ + if (widget.showMapLibre) + 'MapLibre', + ...?snapshot.data, + ]; return Container( alignment: widget.alignment, padding: widget.padding, @@ -77,45 +83,41 @@ class _SourceAttributionState extends State { color: theme.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(20), ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_expanded) ...[ - if (widget.showMapLibre) ...[ - Padding( - padding: EdgeInsets.only( - left: 8, - right: attributions.isEmpty ? 0 : 4, - ), - child: InkWell( - child: Text( - 'MapLibre${attributions.isEmpty ? '' : ' |'}', - style: theme.textTheme.bodySmall, - ), - onTap: () => - launchUrl(Uri.parse('https://maplibre.org/')), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_expanded) + Padding( + padding: const EdgeInsets.only( + bottom: 5, + top: 5, + left: 10, + ), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: size.width / 2), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 2, + runSpacing: 2, + children: attributions + .map(_HtmlWidget.new) + .toList(growable: false), ), - ], - ...attributions.map(_HtmlWidget.new), - ], - // The SizedBox enforces the height on android (web works without it). - SizedBox.square( - dimension: 30, - child: IconButton( - onPressed: () => setState(() { - _initMapCamera = null; - _expanded = !_expanded; - }), - icon: const Icon(Icons.info, size: 18), - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints(), ), ), - ], - ), + SizedBox.square( + dimension: 30, + child: IconButton( + onPressed: () => setState(() { + _initMapCamera = null; + _expanded = !_expanded; + }), + icon: const Icon(Icons.info, size: 18), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), + ), + ], ), ), ), @@ -125,16 +127,26 @@ class _SourceAttributionState extends State { } } -class _HtmlWidget extends StatelessWidget { +class _HtmlWidget extends StatefulWidget { const _HtmlWidget(this.html); final String html; + @override + State createState() => _HtmlWidgetState(); +} + +class _HtmlWidgetState extends State<_HtmlWidget> { + bool _hovering = false; + @override Widget build(BuildContext context) { - final textStyle = Theme.of(context).textTheme.bodySmall; + var textStyle = Theme.of(context).textTheme.bodySmall; + if (_hovering) + textStyle = textStyle?.copyWith(decoration: TextDecoration.underline); final textSpans = []; - final document = html_parser.parse(html); + final document = html_parser.parse(widget.html); + for (final node in document.body!.nodes) { if (node is dom.Text) { // pure text @@ -143,6 +155,8 @@ class _HtmlWidget extends StatelessWidget { // link textSpans.add( TextSpan( + onEnter: (event) => setState(() => _hovering = true), + onExit: (event) => setState(() => _hovering = false), text: node.text, style: textStyle, recognizer: TapGestureRecognizer() From 7acb717fab3c2853508563bd07c9fe067592ff80 Mon Sep 17 00:00:00 2001 From: Joscha <34318751+josxha@users.noreply.github.com> Date: Thu, 13 Feb 2025 21:11:57 +0100 Subject: [PATCH 3/3] fix lint --- lib/src/ui/source_attribution.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/ui/source_attribution.dart b/lib/src/ui/source_attribution.dart index 2e2c8c99..0ed00ee3 100644 --- a/lib/src/ui/source_attribution.dart +++ b/lib/src/ui/source_attribution.dart @@ -142,8 +142,9 @@ class _HtmlWidgetState extends State<_HtmlWidget> { @override Widget build(BuildContext context) { var textStyle = Theme.of(context).textTheme.bodySmall; - if (_hovering) + if (_hovering) { textStyle = textStyle?.copyWith(decoration: TextDecoration.underline); + } final textSpans = []; final document = html_parser.parse(widget.html);