Skip to content

Commit

Permalink
Replace TileLayer's error image option with a placeholder image option
Browse files Browse the repository at this point in the history
When a tile fails to load and there are no existing loaded tiles which
overlay with it from another zoom level we are left with a blank portion
on the map. When there are no loaded tiles visible, for example because
of a network problem, there is no visual feedback when
zooming/panning/rotating because the map is just a single solid color.
This prompted me to add a placeholder image.

We already have an error image implementation but the error image will
not show up until loading fails which, in the case of a poor network
connection, can take quite a while.

The placeholder implementation gives visual feedback both whilst loading
and when loading fails.

Note that the current implementation just fills the viewport with the
placeholder tile behind all other tiles. It would be more efficient to
avoid adding placeholder tiles which are completely obscured by other
visual tiles but my attempts to do this were unsuccessful due to the
complex nature of tile pruning.
  • Loading branch information
rorystephenson committed Aug 2, 2023
1 parent 991f53c commit 08cb80d
Show file tree
Hide file tree
Showing 13 changed files with 299 additions and 75 deletions.
5 changes: 2 additions & 3 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import 'package:flutter_map_example/pages/secondary_tap.dart';
import 'package:flutter_map_example/pages/sliding_map.dart';
import 'package:flutter_map_example/pages/stateful_markers.dart';
import 'package:flutter_map_example/pages/tile_builder_example.dart';
import 'package:flutter_map_example/pages/tile_loading_error_handle.dart';
import 'package:flutter_map_example/pages/tile_error_handling.dart';
import 'package:flutter_map_example/pages/wms_tile_layer.dart';
import 'package:url_strategy/url_strategy.dart';

Expand Down Expand Up @@ -62,8 +62,7 @@ class MyApp extends StatelessWidget {
SlidingMapPage.route: (_) => const SlidingMapPage(),
WMSLayerPage.route: (context) => const WMSLayerPage(),
CustomCrsPage.route: (context) => const CustomCrsPage(),
TileLoadingErrorHandle.route: (context) =>
const TileLoadingErrorHandle(),
TileErrorHandling.route: (context) => const TileErrorHandling(),
TileBuilderPage.route: (context) => const TileBuilderPage(),
InteractiveTestPage.route: (context) => const InteractiveTestPage(),
ManyMarkersPage.route: (context) => const ManyMarkersPage(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,61 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_example/widgets/drawer.dart';
import 'package:latlong2/latlong.dart';

class TileLoadingErrorHandle extends StatefulWidget {
static const String route = '/tile_loading_error_handle';
class TileErrorHandling extends StatefulWidget {
static const String route = '/tile_error_handling';

const TileLoadingErrorHandle({Key? key}) : super(key: key);
const TileErrorHandling({Key? key}) : super(key: key);

@override
_TileLoadingErrorHandleState createState() => _TileLoadingErrorHandleState();
_TileErrorHandlingState createState() => _TileErrorHandlingState();
}

class _TileLoadingErrorHandleState extends State<TileLoadingErrorHandle> {
class _TileErrorHandlingState extends State<TileErrorHandling> {
static const _showSnackBarDuration = Duration(seconds: 1);
static final _placeholderImage = TilePlaceholderImage.generate();

late final TileLayerController _tileLayerController;

bool _simulateTileLoadErrors = false;
DateTime? _lastShowedTileLoadError;

@override
void initState() {
super.initState();
_tileLayerController = TileLayerController();
}

@override
void dispose() {
_tileLayerController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Tile Loading Error Handle')),
drawer: buildDrawer(context, TileLoadingErrorHandle.route),
appBar: AppBar(
title: const Text('Tile Error Handling'),
actions: [
IconButton(
icon: const Icon(Icons.info),
onPressed: () {
showDialog<void>(
context: context,
builder: (context) => const AlertDialog(
title: Text('Tile Error Handling'),
content: Text(
'To trigger tile loading errors enable tile loading error '
'simulation or disable internet access and try to move the '
'map.',
),
),
);
},
)
],
),
drawer: buildDrawer(context, TileErrorHandling.route),
body: Padding(
padding: const EdgeInsets.all(8),
child: Column(
Expand All @@ -36,11 +72,11 @@ class _TileLoadingErrorHandleState extends State<TileLoadingErrorHandle> {
_simulateTileLoadErrors = newValue;
}),
),
const Padding(
padding: EdgeInsets.only(top: 8, bottom: 8),
child: Text(
'Enable tile load error simulation or disable internet and try to move or zoom map.'),
ElevatedButton(
onPressed: () => _tileLayerController.reloadErrorTiles(),
child: const Text('Reload error tiles'),
),
const SizedBox(height: 12),
Flexible(
child: Builder(builder: (BuildContext context) {
return FlutterMap(
Expand All @@ -50,6 +86,8 @@ class _TileLoadingErrorHandleState extends State<TileLoadingErrorHandle> {
),
children: [
TileLayer(
controller: _tileLayerController,
placeholderImage: _placeholderImage,
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'dev.fleaflet.flutter_map.example',
Expand Down
7 changes: 3 additions & 4 deletions example/lib/widgets/drawer.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';

import 'package:flutter_map_example/pages/animated_map_controller.dart';
import 'package:flutter_map_example/pages/circle.dart';
import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart';
Expand All @@ -26,7 +25,7 @@ import 'package:flutter_map_example/pages/secondary_tap.dart';
import 'package:flutter_map_example/pages/sliding_map.dart';
import 'package:flutter_map_example/pages/stateful_markers.dart';
import 'package:flutter_map_example/pages/tile_builder_example.dart';
import 'package:flutter_map_example/pages/tile_loading_error_handle.dart';
import 'package:flutter_map_example/pages/tile_error_handling.dart';
import 'package:flutter_map_example/pages/wms_tile_layer.dart';

Widget _buildMenuItem(
Expand Down Expand Up @@ -228,8 +227,8 @@ Drawer buildDrawer(BuildContext context, String currentRoute) {
const Divider(),
_buildMenuItem(
context,
const Text('Custom Tile Error Handling'),
TileLoadingErrorHandle.route,
const Text('Tile Error Handling'),
TileErrorHandling.route,
currentRoute,
),
_buildMenuItem(
Expand Down
3 changes: 3 additions & 0 deletions lib/flutter_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ export 'package:flutter_map/src/layer/marker_layer.dart';
export 'package:flutter_map/src/layer/overlay_image_layer.dart';
export 'package:flutter_map/src/layer/polygon_layer.dart';
export 'package:flutter_map/src/layer/polyline_layer.dart';
export 'package:flutter_map/src/layer/tile_layer/controller/tile_layer_controller.dart'
hide TileLayerControllerImpl;
export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_display.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_image.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_placeholder_image.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart';
export 'package:flutter_map/src/layer/tile_layer/tile_provider/file_providers/tile_provider_stub.dart'
Expand Down
30 changes: 30 additions & 0 deletions lib/src/layer/tile_layer/controller/tile_layer_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'dart:async';

sealed class TileLayerController {
factory TileLayerController() => TileLayerControllerImpl();

/// Trigger reloading of tiles which failed to load.
void reloadErrorTiles();

/// Dispose of this controller, should be called when this TileLayerController
/// is no longer used.
void dispose();
}

class TileLayerControllerImpl implements TileLayerController {
final StreamController<void> _streamController;

TileLayerControllerImpl() : _streamController = StreamController.broadcast();

Stream<void> get stream => _streamController.stream;

@override
void reloadErrorTiles() {
_streamController.add(null);
}

@override
void dispose() {
_streamController.close();
}
}
9 changes: 1 addition & 8 deletions lib/src/layer/tile_layer/tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,7 @@ class _TileState extends State<Tile> {
}

Widget get _tileImage {
if (widget.tileImage.loadError && widget.tileImage.errorImage != null) {
return Image(
image: widget.tileImage.errorImage!,
opacity: widget.tileImage.opacity == 1
? null
: AlwaysStoppedAnimation(widget.tileImage.opacity),
);
} else if (widget.tileImage.animation == null) {
if (widget.tileImage.animation == null) {
return RawImage(
image: widget.tileImage.imageInfo?.image,
fit: BoxFit.fill,
Expand Down
26 changes: 6 additions & 20 deletions lib/src/layer/tile_layer/tile_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class TileImage extends ChangeNotifier {
/// indicate the position of the tile at that zoom level.
final TileCoordinates coordinates;

/// Callback fired when loading finishes with or withut an error. This
/// Callback fired when loading finishes with or without an error. This
/// callback is not triggered after this TileImage is disposed.
final void Function(TileCoordinates coordinates) onLoadComplete;

Expand All @@ -32,13 +32,10 @@ class TileImage extends ChangeNotifier {
/// Options for how the tile image is displayed.
TileDisplay _tileDisplay;

/// An optional image to show when a loading error occurs.
final ImageProvider? errorImage;

ImageProvider imageProvider;

/// True if an error occurred during loading.
bool loadError = false;
bool _loadError = false;

/// When loading started.
DateTime? loadStarted;
Expand All @@ -57,7 +54,6 @@ class TileImage extends ChangeNotifier {
required this.onLoadComplete,
required this.onLoadError,
required TileDisplay tileDisplay,
required this.errorImage,
}) : _tileDisplay = tileDisplay,
_animationController = tileDisplay.when(
instantaneous: (_) => null,
Expand All @@ -67,6 +63,8 @@ class TileImage extends ChangeNotifier {
),
);

bool get loadError => _loadError;

double get opacity => _tileDisplay.when(
instantaneous: (instantaneous) =>
_readyToDisplay ? instantaneous.opacity : 0.0,
Expand Down Expand Up @@ -148,7 +146,7 @@ class TileImage extends ChangeNotifier {
}

void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) {
loadError = false;
_loadError = false;
this.imageInfo = imageInfo;

if (!_disposed) {
Expand All @@ -158,10 +156,9 @@ class TileImage extends ChangeNotifier {
}

void _onImageLoadError(Object exception, StackTrace? stackTrace) {
loadError = true;
_loadError = true;

if (!_disposed) {
if (errorImage != null) _display();
onLoadError(this, exception, stackTrace);
onLoadComplete(coordinates);
}
Expand All @@ -174,17 +171,6 @@ class TileImage extends ChangeNotifier {
final previouslyLoaded = loadFinishedAt != null;
loadFinishedAt = DateTime.now();

if (loadError) {
assert(
errorImage != null,
'A TileImage should not be displayed if loading errors and there is no '
'errorImage to show.',
);
_readyToDisplay = true;
if (!_disposed) notifyListeners();
return;
}

_tileDisplay.when(
instantaneous: (_) {
_readyToDisplay = true;
Expand Down
7 changes: 5 additions & 2 deletions lib/src/layer/tile_layer/tile_image_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ class TileImageManager {

void reloadImages(
TileLayer layer,
TileBounds tileBounds,
) {
TileBounds tileBounds, {
bool Function(TileImage tileImage)? test,
}) {
// If a TileImage's imageInfo is already available when load() is called it
// will call its onLoadComplete callback synchronously which can trigger
// pruning. Since pruning may cause removals from _tiles we must not
Expand All @@ -128,6 +129,8 @@ class TileImageManager {
final tilesToReload = List<TileImage>.from(_tiles.values);

for (final tile in tilesToReload) {
if (test?.call(tile) == false) continue;

tile.imageProvider = layer.tileProvider.getImage(
tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates),
layer,
Expand Down
Loading

0 comments on commit 08cb80d

Please sign in to comment.