diff --git a/example/lib/pages/tile_builder.dart b/example/lib/pages/tile_builder.dart index c6f3391bf..3644fc583 100644 --- a/example/lib/pages/tile_builder.dart +++ b/example/lib/pages/tile_builder.dart @@ -81,6 +81,10 @@ class TileBuilderPageState extends State { onChanged: (v) => setState(() => enableGrid = v), ), const SizedBox.square(dimension: 12), + const Tooltip( + message: 'Show Background Grid While Tiles Load', + child: Icon(Icons.grid_on), + ), const Tooltip( message: 'Show Coordinates', child: Icon(Icons.location_on), @@ -113,16 +117,19 @@ class TileBuilderPageState extends State { ), Expanded( child: FlutterMap( - options: const MapOptions( - initialCenter: LatLng(51.5, -0.09), + options: MapOptions( + initialCenter: const LatLng(51.5, -0.09), initialZoom: 5, + backgroundColor: + darkMode ? Colors.grey[900]! : Colors.grey[200]!, ), children: [ _darkModeContainerIfEnabled( TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.fleaflet.flutter_map.example', + userAgentPackageName: 'dev.fleaflet.flutter_map.demo', + maxNativeZoom: 19, tileBuilder: tileBuilder, ), ), @@ -148,7 +155,6 @@ class TileBuilderPageState extends State { Widget _darkModeContainerIfEnabled(Widget child) { if (!darkMode) return child; - return darkModeTilesContainerBuilder(context, child); } } diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 21fa967b3..1cbe9a978 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -30,6 +30,7 @@ export 'package:flutter_map/src/layer/attribution_layer/simple.dart'; export 'package:flutter_map/src/layer/circle_layer/circle_layer.dart'; export 'package:flutter_map/src/layer/marker_layer/marker_layer.dart'; export 'package:flutter_map/src/layer/overlay_image_layer/overlay_image_layer.dart'; +export 'package:flutter_map/src/layer/placeholder_grid_layer/placeholder_grid_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer/label/deprecated_placements.dart'; export 'package:flutter_map/src/layer/polygon_layer/label/placement_calculators/placement_calculator.dart'; export 'package:flutter_map/src/layer/polygon_layer/polygon_layer.dart'; diff --git a/lib/src/layer/placeholder_grid_layer/painter.dart b/lib/src/layer/placeholder_grid_layer/painter.dart new file mode 100644 index 000000000..9b8fa4544 --- /dev/null +++ b/lib/src/layer/placeholder_grid_layer/painter.dart @@ -0,0 +1,76 @@ +part of 'placeholder_grid_layer.dart'; + +class _BackgroundGridPainter extends CustomPainter { + const _BackgroundGridPainter({ + required this.color, + required this.spacing, + required this.pixelOrigin, + required this.numOfMinors, + required this.majorStrokeWidth, + required this.minorStrokeWidth, + }); + + final Color color; + final double spacing; + final Offset? pixelOrigin; + final int numOfMinors; + final double majorStrokeWidth; + final double minorStrokeWidth; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty || !spacing.isFinite || spacing < 1) return; + + final paint = Paint() + ..color = color + ..strokeWidth = majorStrokeWidth + ..style = PaintingStyle.stroke + ..isAntiAlias = false; + final path = Path(); + + double x = -(pixelOrigin == null ? size.width : pixelOrigin!.dx % spacing); + for (; x < size.width; x += spacing) { + path + ..moveTo(x, 0) + ..lineTo(x, size.height); + } + double y = -(pixelOrigin == null ? size.height : pixelOrigin!.dy % spacing); + for (; y < size.height; y += spacing) { + path + ..moveTo(0, y) + ..lineTo(size.width, y); + } + + canvas.drawPath(path, paint); + + if (numOfMinors == 0) return; + + path.reset(); + + final minorSpacing = spacing / (numOfMinors + 1); + + x = -(pixelOrigin == null ? size.width : pixelOrigin!.dx % minorSpacing); + for (; x < size.width; x += minorSpacing) { + path + ..moveTo(x, 0) + ..lineTo(x, size.height); + } + y = -(pixelOrigin == null ? size.height : pixelOrigin!.dy % minorSpacing); + for (; y < size.height; y += minorSpacing) { + path + ..moveTo(0, y) + ..lineTo(size.width, y); + } + + canvas.drawPath(path, paint..strokeWidth = minorStrokeWidth); + } + + @override + bool shouldRepaint(_BackgroundGridPainter oldDelegate) => + color != oldDelegate.color || + spacing != oldDelegate.spacing || + pixelOrigin != oldDelegate.pixelOrigin || + numOfMinors != oldDelegate.numOfMinors || + majorStrokeWidth != oldDelegate.majorStrokeWidth || + minorStrokeWidth != oldDelegate.majorStrokeWidth; +} diff --git a/lib/src/layer/placeholder_grid_layer/placeholder_grid_layer.dart b/lib/src/layer/placeholder_grid_layer/placeholder_grid_layer.dart new file mode 100644 index 000000000..05267e101 --- /dev/null +++ b/lib/src/layer/placeholder_grid_layer/placeholder_grid_layer.dart @@ -0,0 +1,155 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; + +part 'painter.dart'; + +/// Layer which draws a grid consisting of orthogonal major and minor strokes, +/// configured by a [PlaceholderGridOptions]. +/// +/// This layer is usually drawn by default on the map background, but can be +/// inserted manually. See [MapOptions.backgroundGridOptions] for info. +/// +/// This layer is intended for use in a [FlutterMap]. However, it will work +/// without being placed in one. +class PlaceholderGridLayer extends StatelessWidget { + /// Build a widget which draws a grid consisting of orthogonal major and minor + /// strokes, configured by a [PlaceholderGridOptions]. + /// + /// The widget does not have to be used within a map context. + const PlaceholderGridLayer({ + super.key, + this.gridOptions = const PlaceholderGridOptions(), + }); + + /// Configuration of the grid drawn. + final PlaceholderGridOptions gridOptions; + + @override + Widget build(BuildContext context) { + final camera = MapCamera.maybeOf(context); + + late final autoGridColor = () { + const delta = 0.15; + + if (MapOptions.maybeOf(context)?.backgroundColor case final color?) { + final hsl = HSLColor.fromColor(color); + return hsl + .withLightness( + ((hsl.lightness > 0.5 ? -delta : delta) + hsl.lightness) + .clamp(0, 1), + ) + .toColor(); + } + }(); + final static = gridOptions.static || camera == null; + final spacing = gridOptions.spacing * + (static + ? 1 + : camera.getZoomScale(camera.zoom, camera.zoom.floorToDouble())); + + final painted = CustomPaint( + painter: _BackgroundGridPainter( + color: gridOptions.color ?? autoGridColor ?? const Color(0x14000000), + spacing: spacing, + pixelOrigin: static ? null : camera.pixelOrigin, + numOfMinors: gridOptions.numOfMinors, + majorStrokeWidth: gridOptions.majorStrokeWidth, + minorStrokeWidth: gridOptions.minorStrokeWidth, + ), + ); + if (static) return painted; + return MobileLayerTransformer(child: painted); + } +} + +/// Configuration options for a [PlaceholderGridLayer], which is usually +/// displayed on the map background. +/// +/// The grid consists of orthogonal major and minor strokes. +@immutable +class PlaceholderGridOptions { + /// Color of the grid strokes. + /// + /// Defaults to a slightly ligher or darker shade of the + /// [MapOptions.backgroundColor], based on if it's dark or light respectively. + /// + /// If no inherited [MapOptions] is available, the default color is a + /// translucent black. + final Color? color; + + /// Gap between major strokes (at integer zoom levels when [static] is + /// `false`), in logical pixels. + /// + /// To ensure a grid is visible while tiles are loading in holes, it's + /// recommended to set this to a smaller value than the tile size. + /// + /// Defaults to 64. + final double spacing; + + /// Whether the grid should be static and not move or zoom with the map, + /// instead of its default behaviour of moving and scaling with zoom levels. + /// + /// If no inherited [MapCamera] is available, the grid is always static. + final bool static; + + /// Thickness of major strokes. + /// + /// Defaults to 1.5. + final double majorStrokeWidth; + + /// Thickness of minor strokes. + /// + /// Defaults to 0 (a hairline stroke). + final double minorStrokeWidth; + + /// Number of minor strokes to insert between major strokes. + /// + /// Defaults to 3. Set to 0 to disable minor strokes. + final int numOfMinors; + + /// Configuration options for a grid, usually displayed on the map background. + const PlaceholderGridOptions({ + this.color, + this.spacing = 64, + this.static = false, + this.majorStrokeWidth = 1.5, + this.minorStrokeWidth = 0, + this.numOfMinors = 3, + }) : assert( + spacing >= 1 && spacing < double.infinity, + '`spacing` must be equal to or greater than one, and finite', + ), + assert( + majorStrokeWidth >= 0, + '`majorStrokeWidth` must be equal to or greater than zero', + ), + assert( + minorStrokeWidth >= 0, + '`minorStrokeWidth` must be equal to or greater than zero', + ), + assert( + numOfMinors >= 0, + '`numOfMinors` must be equal to or greater than zero', + ); + + @override + int get hashCode => Object.hash( + color, + spacing, + static, + majorStrokeWidth, + minorStrokeWidth, + numOfMinors, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaceholderGridOptions && + color == other.color && + spacing == other.spacing && + static == other.static && + majorStrokeWidth == other.majorStrokeWidth && + minorStrokeWidth == other.minorStrokeWidth && + numOfMinors == other.numOfMinors; +} diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index 9f8388a34..70889ea20 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -80,10 +80,27 @@ class MapOptions { /// additional zoom level restrictions. final double? maxZoom; - /// The background color of the [FlutterMap] widget, defaults to a - /// yellow grey-ish color. + /// Background color of the [FlutterMap] widget. + /// + /// It is usually recommended to set this to the background color of the app/ + /// screen, such as the scaffold background color held within the inherited + /// theme, or at least a dark color if the app and map run in dark mode. + /// + /// Defaults to a light yellow-gray-ish color. final Color backgroundColor; + /// Configuration of an optional grid to display on the map background. + /// + /// By default, a grid is drawn on top of the [backgroundColor] with a stroke + /// color which will automatically work on most backgrounds. + /// + /// Set to `null` to disable the grid. The grid is automatically disabled if + /// the [backgroundColor] is fully transparent. + /// + /// A grid can be inserted anywhere within the map stack (or outside of the + /// map context) using a [PlaceholderGridLayer] manually. + final PlaceholderGridOptions? backgroundGridOptions; + /// Callback that fires when the map gets tapped or clicked with the /// primary mouse button. This is normally the left mouse button. This /// callback does not fire if the gesture is recognized as a double click. @@ -167,6 +184,7 @@ class MapOptions { this.minZoom, this.maxZoom, this.backgroundColor = const Color(0xFFE0E0E0), + this.backgroundGridOptions = const PlaceholderGridOptions(), this.onTap, this.onSecondaryTap, this.onLongPress, @@ -204,6 +222,7 @@ class MapOptions { minZoom == other.minZoom && maxZoom == other.maxZoom && backgroundColor == other.backgroundColor && + backgroundGridOptions == other.backgroundGridOptions && onTap == other.onTap && onSecondaryTap == other.onSecondaryTap && onLongPress == other.onLongPress && @@ -228,6 +247,7 @@ class MapOptions { minZoom, maxZoom, backgroundColor, + backgroundGridOptions, onTap, onSecondaryTap, onLongPress, diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index 3e95125e6..47dd0026d 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -81,12 +81,15 @@ class _FlutterMapStateContainer extends State Widget build(BuildContext context) { super.build(context); + final backgroundColor = widget.options.backgroundColor; + final widgets = ClipRect( child: Stack( - children: [ - Positioned.fill( - child: ColoredBox(color: widget.options.backgroundColor), - ), + children: [ + Positioned.fill(child: ColoredBox(color: backgroundColor)), + if (widget.options.backgroundGridOptions case final options? + when backgroundColor.a != 0) + Positioned.fill(child: PlaceholderGridLayer(gridOptions: options)), ...widget.children, ], ),