From f2f44da01322e7c52528476c1960cebeb23ea1bc Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Sun, 6 Sep 2026 14:24:16 +1000 Subject: [PATCH 1/3] feat: show a background grid while map tiles load --- README.md | 5 + example/lib/pages/tile_builder.dart | 16 +- lib/src/map/background.dart | 98 ++++++++++ lib/src/map/options/options.dart | 27 ++- lib/src/map/widget.dart | 7 +- test/map/background_test.dart | 266 ++++++++++++++++++++++++++++ 6 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 lib/src/map/background.dart create mode 100644 test/map/background_test.dart diff --git a/README.md b/README.md index 45169a422..3329b264e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ Flutter's №1 non-commercially aimed map client: it's easy-to-use, versatile, vendor-free, fully cross-platform, and 100% pure-Flutter +Maps show a subtle background grid while tiles load. It moves with the map and +is covered as tiles render. Customize it with `MapOptions.backgroundGridColor` +and `MapOptions.backgroundGridSpacing`, or set `backgroundGridColor: null` to +use a plain background. Fully transparent map backgrounds remain transparent. + * ### [📖 **Read the docs**](https://docs.fleaflet.dev/) * ### [🚀 **Launch the web demo**](https://demo.fleaflet.dev/) diff --git a/example/lib/pages/tile_builder.dart b/example/lib/pages/tile_builder.dart index c6f3391bf..33e5013a8 100644 --- a/example/lib/pages/tile_builder.dart +++ b/example/lib/pages/tile_builder.dart @@ -14,6 +14,7 @@ class TileBuilderPage extends StatefulWidget { class TileBuilderPageState extends State { bool enableGrid = true; + bool showBackgroundGrid = true; bool showCoordinates = true; bool showLoadingTime = true; bool darkMode = true; @@ -81,6 +82,15 @@ 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), + ), + Switch.adaptive( + value: showBackgroundGrid, + onChanged: (v) => setState(() => showBackgroundGrid = v), + ), + const SizedBox.square(dimension: 12), const Tooltip( message: 'Show Coordinates', child: Icon(Icons.location_on), @@ -113,9 +123,11 @@ 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, + backgroundGridColor: + showBackgroundGrid ? const Color(0x14000000) : null, ), children: [ _darkModeContainerIfEnabled( diff --git a/lib/src/map/background.dart b/lib/src/map/background.dart new file mode 100644 index 000000000..b96ead28a --- /dev/null +++ b/lib/src/map/background.dart @@ -0,0 +1,98 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/layer/shared/mobile_layer_transformer.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:meta/meta.dart'; + +/// The map background, covered by map layers as they render. +@internal +class MapBackground extends StatelessWidget { + /// Creates the map background and its optional grid. + const MapBackground({ + super.key, + required this.color, + required this.gridColor, + required this.gridSpacing, + }); + + /// The background fill color. + final Color color; + + /// The grid line color, or `null` to disable the grid. + final Color? gridColor; + + /// The grid cell size at integer zoom levels. + final double gridSpacing; + + @override + Widget build(BuildContext context) { + final gridColor = this.gridColor; + if (gridColor == null || gridColor.a == 0 || color.a == 0) { + return ColoredBox(color: color); + } + final camera = MapCamera.of(context); + + return RepaintBoundary( + child: ColoredBox( + color: color, + child: IgnorePointer( + child: MobileLayerTransformer( + child: CustomPaint( + painter: _BackgroundGridPainter( + color: gridColor, + spacing: gridSpacing * + camera.getZoomScale( + camera.zoom, + camera.zoom.floorToDouble(), + ), + pixelOrigin: camera.pixelOrigin, + ), + ), + ), + ), + ), + ); + } +} + +class _BackgroundGridPainter extends CustomPainter { + const _BackgroundGridPainter({ + required this.color, + required this.spacing, + required this.pixelOrigin, + }); + + final Color color; + final double spacing; + final Offset pixelOrigin; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty || !spacing.isFinite || spacing < 1) return; + + final paint = Paint() + ..color = color + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + final path = Path(); + + // Reduce the projected coordinates before drawing so the work depends on + // viewport size, even at high zoom levels or across repeated worlds. + for (var x = -pixelOrigin.dx % spacing; x < size.width; x += spacing) { + path + ..moveTo(x, 0) + ..lineTo(x, size.height); + } + for (var y = -pixelOrigin.dy % spacing; y < size.height; y += spacing) { + path + ..moveTo(0, y) + ..lineTo(size.width, y); + } + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_BackgroundGridPainter oldDelegate) => + color != oldDelegate.color || + spacing != oldDelegate.spacing || + pixelOrigin != oldDelegate.pixelOrigin; +} diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index 9f8388a34..a3fb1f5ef 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -84,6 +84,22 @@ class MapOptions { /// yellow grey-ish color. final Color backgroundColor; + /// Color of the grid drawn over [backgroundColor], beneath all map layers. + /// + /// The grid is visible while tiles load and is covered as opaque tiles fade + /// in. It also remains visible through transparent tiles and in empty areas. + /// Set to `null` to disable it. A fully transparent [backgroundColor] also + /// disables the grid. + /// + /// Enabled by default with a subtle black line color (`0x14000000`). + final Color? backgroundGridColor; + + /// Grid cell size in logical pixels at integer zoom levels. + /// + /// The grid moves and rotates with the map, scaling between integer zoom + /// levels. Must be finite and at least 1. Defaults to 64. + final double backgroundGridSpacing; + /// 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 +183,8 @@ class MapOptions { this.minZoom, this.maxZoom, this.backgroundColor = const Color(0xFFE0E0E0), + this.backgroundGridColor = const Color(0x14000000), + this.backgroundGridSpacing = 64, this.onTap, this.onSecondaryTap, this.onLongPress, @@ -179,7 +197,10 @@ class MapOptions { this.onMapEvent, this.onMapReady, this.keepAlive = false, - }); + }) : assert( + backgroundGridSpacing >= 1 && backgroundGridSpacing < double.infinity, + 'backgroundGridSpacing must be finite and at least 1', + ); /// The options of the closest [FlutterMap] ancestor. If this is called from a /// context with no [FlutterMap] ancestor, null is returned. @@ -204,6 +225,8 @@ class MapOptions { minZoom == other.minZoom && maxZoom == other.maxZoom && backgroundColor == other.backgroundColor && + backgroundGridColor == other.backgroundGridColor && + backgroundGridSpacing == other.backgroundGridSpacing && onTap == other.onTap && onSecondaryTap == other.onSecondaryTap && onLongPress == other.onLongPress && @@ -228,6 +251,8 @@ class MapOptions { minZoom, maxZoom, backgroundColor, + backgroundGridColor, + backgroundGridSpacing, onTap, onSecondaryTap, onLongPress, diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index 3e95125e6..f08410e75 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; +import 'package:flutter_map/src/map/background.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; /// An interactive geographical map @@ -85,7 +86,11 @@ class _FlutterMapStateContainer extends State child: Stack( children: [ Positioned.fill( - child: ColoredBox(color: widget.options.backgroundColor), + child: MapBackground( + color: widget.options.backgroundColor, + gridColor: widget.options.backgroundGridColor, + gridSpacing: widget.options.backgroundGridSpacing, + ), ), ...widget.children, ], diff --git a/test/map/background_test.dart b/test/map/background_test.dart new file mode 100644 index 000000000..8c4eb456c --- /dev/null +++ b/test/map/background_test.dart @@ -0,0 +1,266 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +const _captureKey = ValueKey('map capture'); + +Widget _map({ + MapController? controller, + MapOptions options = const MapOptions( + initialCenter: LatLng(0, 0), + initialZoom: 1, + ), + List children = const [], +}) => + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 256, + child: RepaintBoundary( + key: _captureKey, + child: FlutterMap( + mapController: controller, + options: options, + children: children, + ), + ), + ), + ), + ); + +Future _pixels(WidgetTester tester) async { + final boundary = tester.renderObject( + find.byKey(_captureKey), + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + try { + return (await image.toByteData())!; + } finally { + image.dispose(); + } + }))!; +} + +int _red(ByteData pixels, int x, int y) => pixels.getUint8((y * 256 + x) * 4); + +Future _whiteImage(WidgetTester tester) async => + (await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(const Color(0xFFFFFFFF), BlendMode.src); + final picture = recorder.endRecording(); + try { + return await picture.toImage(1, 1); + } finally { + picture.dispose(); + } + }))!; + +void main() { + for (final fade in [false, true]) { + testWidgets('grid is covered per tile as it loads (fade: $fade)', + (tester) async { + final provider = _DeferredTileProvider(); + final white = await _whiteImage(tester); + addTearDown(white.dispose); + + await tester.pumpWidget(_map(children: [ + TileLayer( + tileProvider: provider, + tileDisplay: fade + ? const TileDisplay.fadeIn( + duration: Duration(milliseconds: 200), + ) + : const TileDisplay.instantaneous(), + ), + ])); + + final loading = await _pixels(tester); + expect(_red(loading, 32, 32), 224); + expect(_red(loading, 63, 32), lessThan(224)); + expect(_red(loading, 32, 63), lessThan(224)); + + // Complete only the top-left tile. Other tiles must keep their grid. + provider.images[const TileCoordinates(0, 0, 1)]!.complete(white); + await tester.pump(); + if (fade) { + await tester.pump(const Duration(milliseconds: 100)); + final fading = await _pixels(tester); + expect( + _red(fading, 63, 32), inExclusiveRange(_red(loading, 63, 32), 255)); + } + await tester.pump(const Duration(milliseconds: 200)); + + final partial = await _pixels(tester); + expect(_red(partial, 63, 32), 255); + expect(_red(partial, 191, 224), _red(loading, 191, 224)); + + for (final image in provider.images.values) { + if (!image.completer.isCompleted) image.complete(white); + } + await tester.pump(); + await tester.pumpAndSettle(); + + final loaded = await _pixels(tester); + expect(loaded.buffer.asUint8List(), everyElement(255)); + }); + } + + testWidgets('loaded fallback tiles stay above the grid while zooming', + (tester) async { + final controller = MapController(); + addTearDown(controller.dispose); + final provider = _DeferredTileProvider(); + final white = await _whiteImage(tester); + addTearDown(white.dispose); + await tester.pumpWidget(_map(controller: controller, children: [ + TileLayer( + tileProvider: provider, + tileDisplay: const TileDisplay.instantaneous(), + ), + ])); + for (final image in provider.images.values) { + image.complete(white); + } + await tester.pumpAndSettle(); + + controller.move(const LatLng(0, 0), 2); + await tester.pump(); + expect( + provider.images.keys.any((coordinates) => coordinates.z == 2), isTrue); + final zooming = await _pixels(tester); + expect(zooming.buffer.asUint8List(), everyElement(255)); + }); + + testWidgets('grid follows panning, fractional zoom and rotation', + (tester) async { + final controller = MapController(); + addTearDown(controller.dispose); + await tester.pumpWidget(_map(controller: controller)); + + final camera = controller.camera; + controller.move( + camera.unprojectAtZoom( + camera.projectAtZoom(camera.center) + const Offset(16, 0), + ), + camera.zoom, + ); + await tester.pump(); + final panned = await _pixels(tester); + expect(_red(panned, 63, 32), 224); + expect(_red(panned, 47, 32), lessThan(224)); + + controller.move(const LatLng(0, 0), 1.5); + await tester.pump(); + final zoomed = await _pixels(tester); + // The line left of the center is now 64 * sqrt(2) pixels away. + expect(_red(zoomed, 37, 32), lessThan(224)); + expect(_red(zoomed, 63, 32), 224); + + controller.move(const LatLng(0, 0), 1); + controller.rotate(45); + await tester.pump(); + final rotated = await _pixels(tester); + expect(_red(rotated, 100, 100), lessThan(224)); + expect(_red(rotated, 100, 110), 224); + expect(_red(rotated, 0, 0), lessThan(224)); + expect(_red(rotated, 255, 255), lessThan(224)); + }); + + testWidgets('grid color, spacing and disabling update on rebuild', + (tester) async { + await tester.pumpWidget(_map()); + await tester.pumpWidget(_map( + options: const MapOptions( + initialCenter: LatLng(0, 0), + initialZoom: 1, + backgroundGridColor: Color(0xFF000000), + backgroundGridSpacing: 32, + ), + )); + final custom = await _pixels(tester); + expect(_red(custom, 31, 16), lessThan(150)); + expect(_red(custom, 16, 16), 224); + + await tester.pumpWidget(_map( + options: const MapOptions(backgroundGridColor: null), + )); + final disabled = await _pixels(tester); + expect(_red(disabled, 31, 16), 224); + expect(_red(disabled, 63, 32), 224); + }); + + testWidgets('transparent map backgrounds stay transparent', (tester) async { + await tester.pumpWidget(_map( + options: const MapOptions(backgroundColor: Color(0x00000000)), + )); + final transparent = await _pixels(tester); + expect(transparent.buffer.asUint8List(), everyElement(0)); + }); + + testWidgets('grid does not intercept map taps or drags', (tester) async { + final controller = MapController(); + addTearDown(controller.dispose); + var taps = 0; + await tester.pumpWidget(_map( + controller: controller, + options: MapOptions(onTap: (_, __) => taps++), + )); + await tester.tap(find.byType(FlutterMap)); + // Allow the map's double-tap recognition window to expire. + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(taps, 1); + + final center = controller.camera.center; + await tester.drag(find.byType(FlutterMap), const Offset(50, 20)); + await tester.pumpAndSettle(); + expect(controller.camera.center, isNot(center)); + }); + + test('grid options participate in equality and reject invalid spacing', () { + const defaults = MapOptions(); + const disabled = MapOptions(backgroundGridColor: null); + const spacing = MapOptions(backgroundGridSpacing: 32); + expect(defaults, isNot(disabled)); + expect(defaults, isNot(spacing)); + expect({defaults, disabled, spacing}, hasLength(3)); + for (final invalid in [0.0, -1.0, double.infinity, double.nan]) { + expect(() => MapOptions(backgroundGridSpacing: invalid), + throwsAssertionError); + } + }); +} + +class _DeferredTileProvider extends TileProvider { + final images = {}; + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => + images.putIfAbsent(coordinates, _DeferredImage.new); +} + +class _DeferredImage extends ImageProvider<_DeferredImage> { + final completer = Completer(); + + void complete(ui.Image image) => + completer.complete(ImageInfo(image: image.clone())); + + @override + Future<_DeferredImage> obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + _DeferredImage key, + ImageDecoderCallback decode, + ) => + OneFrameImageStreamCompleter(completer.future); +} From 3ddfbe2a3accc2ff7e14caaa542d4c2308a052bd Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 13 Sep 2026 23:34:45 +0100 Subject: [PATCH 2/3] Split grid into seperate layer Automatically compute grid color based on background color Add more customisation options to grid Remove over-the-top test suite --- example/lib/pages/tile_builder.dart | 14 +- lib/flutter_map.dart | 1 + .../layer/placeholder_grid_layer/painter.dart | 76 +++++ .../placeholder_grid_layer.dart | 155 ++++++++++ lib/src/map/background.dart | 98 ------- lib/src/map/options/options.dart | 43 ++- lib/src/map/widget.dart | 16 +- test/map/background_test.dart | 266 ------------------ 8 files changed, 262 insertions(+), 407 deletions(-) create mode 100644 lib/src/layer/placeholder_grid_layer/painter.dart create mode 100644 lib/src/layer/placeholder_grid_layer/placeholder_grid_layer.dart delete mode 100644 lib/src/map/background.dart delete mode 100644 test/map/background_test.dart diff --git a/example/lib/pages/tile_builder.dart b/example/lib/pages/tile_builder.dart index 33e5013a8..3644fc583 100644 --- a/example/lib/pages/tile_builder.dart +++ b/example/lib/pages/tile_builder.dart @@ -14,7 +14,6 @@ class TileBuilderPage extends StatefulWidget { class TileBuilderPageState extends State { bool enableGrid = true; - bool showBackgroundGrid = true; bool showCoordinates = true; bool showLoadingTime = true; bool darkMode = true; @@ -86,11 +85,6 @@ class TileBuilderPageState extends State { message: 'Show Background Grid While Tiles Load', child: Icon(Icons.grid_on), ), - Switch.adaptive( - value: showBackgroundGrid, - onChanged: (v) => setState(() => showBackgroundGrid = v), - ), - const SizedBox.square(dimension: 12), const Tooltip( message: 'Show Coordinates', child: Icon(Icons.location_on), @@ -126,15 +120,16 @@ class TileBuilderPageState extends State { options: MapOptions( initialCenter: const LatLng(51.5, -0.09), initialZoom: 5, - backgroundGridColor: - showBackgroundGrid ? const Color(0x14000000) : null, + 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, ), ), @@ -160,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/background.dart b/lib/src/map/background.dart deleted file mode 100644 index b96ead28a..000000000 --- a/lib/src/map/background.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/shared/mobile_layer_transformer.dart'; -import 'package:flutter_map/src/map/camera/camera.dart'; -import 'package:meta/meta.dart'; - -/// The map background, covered by map layers as they render. -@internal -class MapBackground extends StatelessWidget { - /// Creates the map background and its optional grid. - const MapBackground({ - super.key, - required this.color, - required this.gridColor, - required this.gridSpacing, - }); - - /// The background fill color. - final Color color; - - /// The grid line color, or `null` to disable the grid. - final Color? gridColor; - - /// The grid cell size at integer zoom levels. - final double gridSpacing; - - @override - Widget build(BuildContext context) { - final gridColor = this.gridColor; - if (gridColor == null || gridColor.a == 0 || color.a == 0) { - return ColoredBox(color: color); - } - final camera = MapCamera.of(context); - - return RepaintBoundary( - child: ColoredBox( - color: color, - child: IgnorePointer( - child: MobileLayerTransformer( - child: CustomPaint( - painter: _BackgroundGridPainter( - color: gridColor, - spacing: gridSpacing * - camera.getZoomScale( - camera.zoom, - camera.zoom.floorToDouble(), - ), - pixelOrigin: camera.pixelOrigin, - ), - ), - ), - ), - ), - ); - } -} - -class _BackgroundGridPainter extends CustomPainter { - const _BackgroundGridPainter({ - required this.color, - required this.spacing, - required this.pixelOrigin, - }); - - final Color color; - final double spacing; - final Offset pixelOrigin; - - @override - void paint(Canvas canvas, Size size) { - if (size.isEmpty || !spacing.isFinite || spacing < 1) return; - - final paint = Paint() - ..color = color - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; - final path = Path(); - - // Reduce the projected coordinates before drawing so the work depends on - // viewport size, even at high zoom levels or across repeated worlds. - for (var x = -pixelOrigin.dx % spacing; x < size.width; x += spacing) { - path - ..moveTo(x, 0) - ..lineTo(x, size.height); - } - for (var y = -pixelOrigin.dy % spacing; y < size.height; y += spacing) { - path - ..moveTo(0, y) - ..lineTo(size.width, y); - } - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(_BackgroundGridPainter oldDelegate) => - color != oldDelegate.color || - spacing != oldDelegate.spacing || - pixelOrigin != oldDelegate.pixelOrigin; -} diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index a3fb1f5ef..70889ea20 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -80,25 +80,26 @@ 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; - /// Color of the grid drawn over [backgroundColor], beneath all map layers. + /// Configuration of an optional grid to display on the map background. /// - /// The grid is visible while tiles load and is covered as opaque tiles fade - /// in. It also remains visible through transparent tiles and in empty areas. - /// Set to `null` to disable it. A fully transparent [backgroundColor] also - /// disables the grid. + /// By default, a grid is drawn on top of the [backgroundColor] with a stroke + /// color which will automatically work on most backgrounds. /// - /// Enabled by default with a subtle black line color (`0x14000000`). - final Color? backgroundGridColor; - - /// Grid cell size in logical pixels at integer zoom levels. + /// Set to `null` to disable the grid. The grid is automatically disabled if + /// the [backgroundColor] is fully transparent. /// - /// The grid moves and rotates with the map, scaling between integer zoom - /// levels. Must be finite and at least 1. Defaults to 64. - final double backgroundGridSpacing; + /// 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 @@ -183,8 +184,7 @@ class MapOptions { this.minZoom, this.maxZoom, this.backgroundColor = const Color(0xFFE0E0E0), - this.backgroundGridColor = const Color(0x14000000), - this.backgroundGridSpacing = 64, + this.backgroundGridOptions = const PlaceholderGridOptions(), this.onTap, this.onSecondaryTap, this.onLongPress, @@ -197,10 +197,7 @@ class MapOptions { this.onMapEvent, this.onMapReady, this.keepAlive = false, - }) : assert( - backgroundGridSpacing >= 1 && backgroundGridSpacing < double.infinity, - 'backgroundGridSpacing must be finite and at least 1', - ); + }); /// The options of the closest [FlutterMap] ancestor. If this is called from a /// context with no [FlutterMap] ancestor, null is returned. @@ -225,8 +222,7 @@ class MapOptions { minZoom == other.minZoom && maxZoom == other.maxZoom && backgroundColor == other.backgroundColor && - backgroundGridColor == other.backgroundGridColor && - backgroundGridSpacing == other.backgroundGridSpacing && + backgroundGridOptions == other.backgroundGridOptions && onTap == other.onTap && onSecondaryTap == other.onSecondaryTap && onLongPress == other.onLongPress && @@ -251,8 +247,7 @@ class MapOptions { minZoom, maxZoom, backgroundColor, - backgroundGridColor, - backgroundGridSpacing, + backgroundGridOptions, onTap, onSecondaryTap, onLongPress, diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index f08410e75..47dd0026d 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; -import 'package:flutter_map/src/map/background.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; /// An interactive geographical map @@ -82,16 +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: MapBackground( - color: widget.options.backgroundColor, - gridColor: widget.options.backgroundGridColor, - gridSpacing: widget.options.backgroundGridSpacing, - ), - ), + 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, ], ), diff --git a/test/map/background_test.dart b/test/map/background_test.dart deleted file mode 100644 index 8c4eb456c..000000000 --- a/test/map/background_test.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:latlong2/latlong.dart'; - -const _captureKey = ValueKey('map capture'); - -Widget _map({ - MapController? controller, - MapOptions options = const MapOptions( - initialCenter: LatLng(0, 0), - initialZoom: 1, - ), - List children = const [], -}) => - Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: SizedBox.square( - dimension: 256, - child: RepaintBoundary( - key: _captureKey, - child: FlutterMap( - mapController: controller, - options: options, - children: children, - ), - ), - ), - ), - ); - -Future _pixels(WidgetTester tester) async { - final boundary = tester.renderObject( - find.byKey(_captureKey), - ); - return (await tester.runAsync(() async { - final image = await boundary.toImage(); - try { - return (await image.toByteData())!; - } finally { - image.dispose(); - } - }))!; -} - -int _red(ByteData pixels, int x, int y) => pixels.getUint8((y * 256 + x) * 4); - -Future _whiteImage(WidgetTester tester) async => - (await tester.runAsync(() async { - final recorder = ui.PictureRecorder(); - Canvas(recorder).drawColor(const Color(0xFFFFFFFF), BlendMode.src); - final picture = recorder.endRecording(); - try { - return await picture.toImage(1, 1); - } finally { - picture.dispose(); - } - }))!; - -void main() { - for (final fade in [false, true]) { - testWidgets('grid is covered per tile as it loads (fade: $fade)', - (tester) async { - final provider = _DeferredTileProvider(); - final white = await _whiteImage(tester); - addTearDown(white.dispose); - - await tester.pumpWidget(_map(children: [ - TileLayer( - tileProvider: provider, - tileDisplay: fade - ? const TileDisplay.fadeIn( - duration: Duration(milliseconds: 200), - ) - : const TileDisplay.instantaneous(), - ), - ])); - - final loading = await _pixels(tester); - expect(_red(loading, 32, 32), 224); - expect(_red(loading, 63, 32), lessThan(224)); - expect(_red(loading, 32, 63), lessThan(224)); - - // Complete only the top-left tile. Other tiles must keep their grid. - provider.images[const TileCoordinates(0, 0, 1)]!.complete(white); - await tester.pump(); - if (fade) { - await tester.pump(const Duration(milliseconds: 100)); - final fading = await _pixels(tester); - expect( - _red(fading, 63, 32), inExclusiveRange(_red(loading, 63, 32), 255)); - } - await tester.pump(const Duration(milliseconds: 200)); - - final partial = await _pixels(tester); - expect(_red(partial, 63, 32), 255); - expect(_red(partial, 191, 224), _red(loading, 191, 224)); - - for (final image in provider.images.values) { - if (!image.completer.isCompleted) image.complete(white); - } - await tester.pump(); - await tester.pumpAndSettle(); - - final loaded = await _pixels(tester); - expect(loaded.buffer.asUint8List(), everyElement(255)); - }); - } - - testWidgets('loaded fallback tiles stay above the grid while zooming', - (tester) async { - final controller = MapController(); - addTearDown(controller.dispose); - final provider = _DeferredTileProvider(); - final white = await _whiteImage(tester); - addTearDown(white.dispose); - await tester.pumpWidget(_map(controller: controller, children: [ - TileLayer( - tileProvider: provider, - tileDisplay: const TileDisplay.instantaneous(), - ), - ])); - for (final image in provider.images.values) { - image.complete(white); - } - await tester.pumpAndSettle(); - - controller.move(const LatLng(0, 0), 2); - await tester.pump(); - expect( - provider.images.keys.any((coordinates) => coordinates.z == 2), isTrue); - final zooming = await _pixels(tester); - expect(zooming.buffer.asUint8List(), everyElement(255)); - }); - - testWidgets('grid follows panning, fractional zoom and rotation', - (tester) async { - final controller = MapController(); - addTearDown(controller.dispose); - await tester.pumpWidget(_map(controller: controller)); - - final camera = controller.camera; - controller.move( - camera.unprojectAtZoom( - camera.projectAtZoom(camera.center) + const Offset(16, 0), - ), - camera.zoom, - ); - await tester.pump(); - final panned = await _pixels(tester); - expect(_red(panned, 63, 32), 224); - expect(_red(panned, 47, 32), lessThan(224)); - - controller.move(const LatLng(0, 0), 1.5); - await tester.pump(); - final zoomed = await _pixels(tester); - // The line left of the center is now 64 * sqrt(2) pixels away. - expect(_red(zoomed, 37, 32), lessThan(224)); - expect(_red(zoomed, 63, 32), 224); - - controller.move(const LatLng(0, 0), 1); - controller.rotate(45); - await tester.pump(); - final rotated = await _pixels(tester); - expect(_red(rotated, 100, 100), lessThan(224)); - expect(_red(rotated, 100, 110), 224); - expect(_red(rotated, 0, 0), lessThan(224)); - expect(_red(rotated, 255, 255), lessThan(224)); - }); - - testWidgets('grid color, spacing and disabling update on rebuild', - (tester) async { - await tester.pumpWidget(_map()); - await tester.pumpWidget(_map( - options: const MapOptions( - initialCenter: LatLng(0, 0), - initialZoom: 1, - backgroundGridColor: Color(0xFF000000), - backgroundGridSpacing: 32, - ), - )); - final custom = await _pixels(tester); - expect(_red(custom, 31, 16), lessThan(150)); - expect(_red(custom, 16, 16), 224); - - await tester.pumpWidget(_map( - options: const MapOptions(backgroundGridColor: null), - )); - final disabled = await _pixels(tester); - expect(_red(disabled, 31, 16), 224); - expect(_red(disabled, 63, 32), 224); - }); - - testWidgets('transparent map backgrounds stay transparent', (tester) async { - await tester.pumpWidget(_map( - options: const MapOptions(backgroundColor: Color(0x00000000)), - )); - final transparent = await _pixels(tester); - expect(transparent.buffer.asUint8List(), everyElement(0)); - }); - - testWidgets('grid does not intercept map taps or drags', (tester) async { - final controller = MapController(); - addTearDown(controller.dispose); - var taps = 0; - await tester.pumpWidget(_map( - controller: controller, - options: MapOptions(onTap: (_, __) => taps++), - )); - await tester.tap(find.byType(FlutterMap)); - // Allow the map's double-tap recognition window to expire. - await tester.pump(const Duration(milliseconds: 300)); - await tester.pumpAndSettle(); - expect(taps, 1); - - final center = controller.camera.center; - await tester.drag(find.byType(FlutterMap), const Offset(50, 20)); - await tester.pumpAndSettle(); - expect(controller.camera.center, isNot(center)); - }); - - test('grid options participate in equality and reject invalid spacing', () { - const defaults = MapOptions(); - const disabled = MapOptions(backgroundGridColor: null); - const spacing = MapOptions(backgroundGridSpacing: 32); - expect(defaults, isNot(disabled)); - expect(defaults, isNot(spacing)); - expect({defaults, disabled, spacing}, hasLength(3)); - for (final invalid in [0.0, -1.0, double.infinity, double.nan]) { - expect(() => MapOptions(backgroundGridSpacing: invalid), - throwsAssertionError); - } - }); -} - -class _DeferredTileProvider extends TileProvider { - final images = {}; - - @override - ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => - images.putIfAbsent(coordinates, _DeferredImage.new); -} - -class _DeferredImage extends ImageProvider<_DeferredImage> { - final completer = Completer(); - - void complete(ui.Image image) => - completer.complete(ImageInfo(image: image.clone())); - - @override - Future<_DeferredImage> obtainKey(ImageConfiguration configuration) => - SynchronousFuture(this); - - @override - ImageStreamCompleter loadImage( - _DeferredImage key, - ImageDecoderCallback decode, - ) => - OneFrameImageStreamCompleter(completer.future); -} From 6b76f736d23428efbecd96545121bc3ec3b1b5c1 Mon Sep 17 00:00:00 2001 From: Luka Stillingfleet Date: Thu, 8 Oct 2026 22:50:56 +0100 Subject: [PATCH 3/3] Discard changes to README.md --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index 3329b264e..45169a422 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,6 @@ Flutter's №1 non-commercially aimed map client: it's easy-to-use, versatile, vendor-free, fully cross-platform, and 100% pure-Flutter -Maps show a subtle background grid while tiles load. It moves with the map and -is covered as tiles render. Customize it with `MapOptions.backgroundGridColor` -and `MapOptions.backgroundGridSpacing`, or set `backgroundGridColor: null` to -use a plain background. Fully transparent map backgrounds remain transparent. - * ### [📖 **Read the docs**](https://docs.fleaflet.dev/) * ### [🚀 **Launch the web demo**](https://demo.fleaflet.dev/)