Skip to content

Commit

Permalink
Only create placeholder tiles where tiles are missing or not fully tr…
Browse files Browse the repository at this point in the history
…ansitioned at the current zoom

This reduces the number of placeholder widgets. The algorithm used to
determine which coordinates to show placeholders for is a tradeoff
between speed and the number of redundant placeholders created. It
creates a placeholder for every tile at the current zoom which failed to
load or is still transitioning. This means that if a tile from a lower
zoom obscures the placeholder or multiple tiles from a higher zoom
collectively obscure the placeholder it will be unnecessarily created.
It would be possible to avoid this but it would require a more complex
data structure or iterating all of the tiles for every potential
placeholder.
  • Loading branch information
rorystephenson committed Aug 3, 2023
1 parent 08cb80d commit d5a6cb7
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 54 deletions.
39 changes: 20 additions & 19 deletions lib/src/layer/tile_layer/tile_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ class TileImage extends ChangeNotifier {
// Controls fade-in opacity.
AnimationController? _animationController;

// Whether the tile is displayable. See [readyToDisplay].
bool _readyToDisplay = false;
// Whether the tile has both loaded and finished transitioning. See
// [transitionComplete].
bool _transitionComplete = false;

/// Used by animationController. Still required if animation is disabled in
/// case the tile display is changed at a later point.
Expand Down Expand Up @@ -67,22 +68,22 @@ class TileImage extends ChangeNotifier {

double get opacity => _tileDisplay.when(
instantaneous: (instantaneous) =>
_readyToDisplay ? instantaneous.opacity : 0.0,
_transitionComplete ? instantaneous.opacity : 0.0,
fadeIn: (fadeIn) => _animationController!.value,
)!;

AnimationController? get animation => _animationController;

String get coordinatesKey => coordinates.key;

/// Whether the tile is displayable. This means that either:
/// * Loading errored but an error image is configured.
/// * Loading succeeded and the fade animation has finished.
/// * Loading succeeded and there is no fade animation.
/// Whether the tile has loaded and finished fading in. This is true when
/// loading succeeded and:
/// * The fade animation has finished.
/// * There is no fade animation.
///
/// Note that [opacity] can be less than 1 when this is true if instantaneous
/// tile display is used with a maximum opacity less than 1.
bool get readyToDisplay => _readyToDisplay;
bool get transitionComplete => _transitionComplete;

// Used to sort TileImages by their distance from the current zoom.
double zIndex(double maxZoom, int currentZoom) =>
Expand All @@ -102,7 +103,7 @@ class TileImage extends ChangeNotifier {
_animationController = AnimationController(
duration: fadeIn.duration,
vsync: vsync,
value: _readyToDisplay ? 1.0 : 0.0,
value: _transitionComplete ? 1.0 : 0.0,
);
},
);
Expand Down Expand Up @@ -150,7 +151,7 @@ class TileImage extends ChangeNotifier {
this.imageInfo = imageInfo;

if (!_disposed) {
_display();
_initiateTransition();
onLoadComplete(coordinates);
}
}
Expand All @@ -164,29 +165,29 @@ class TileImage extends ChangeNotifier {
}
}

// Initiates fading in and marks this TileImage as readyToDisplay when fading
// finishes. If fading is disabled or a loading error occurred this TileImage
// becomes readyToDisplay immediately.
void _display() {
// Initiates fading in and sets [transitionComplete] to true when fading
// finishes. If fading is disabled [transitionComplete] is set to true
// immediately.
void _initiateTransition() {
final previouslyLoaded = loadFinishedAt != null;
loadFinishedAt = DateTime.now();

_tileDisplay.when(
instantaneous: (_) {
_readyToDisplay = true;
_transitionComplete = true;
if (!_disposed) notifyListeners();
},
fadeIn: (fadeIn) {
final fadeStartOpacity =
previouslyLoaded ? fadeIn.reloadStartOpacity : fadeIn.startOpacity;

if (fadeStartOpacity == 1.0) {
_readyToDisplay = true;
_transitionComplete = true;
if (!_disposed) notifyListeners();
} else {
_animationController!.reset();
_animationController!.forward(from: fadeStartOpacity).then((_) {
_readyToDisplay = true;
_transitionComplete = true;
if (!_disposed) notifyListeners();
});
}
Expand All @@ -212,7 +213,7 @@ class TileImage extends ChangeNotifier {
}
}

_readyToDisplay = false;
_transitionComplete = false;
_animationController?.stop(canceled: false);
_animationController?.value = 0.0;
notifyListeners();
Expand All @@ -232,6 +233,6 @@ class TileImage extends ChangeNotifier {

@override
String toString() {
return 'TileImage($coordinates, readyToDisplay: $_readyToDisplay)';
return 'TileImage($coordinates, transitionComplete: $_transitionComplete)';
}
}
23 changes: 10 additions & 13 deletions lib/src/layer/tile_layer/tile_image_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,16 @@ class TileImageManager {
bool get allLoaded =>
_tiles.values.none((tile) => tile.loadFinishedAt == null);

// Returns in the order in which they should be rendered:
// 1. Tiles at the current zoom.
// 2. Tiles at the current zoom +/- 1.
// 3. Tiles at the current zoom +/- 2.
// 4. ...etc
List<TileImage> inRenderOrder(double maxZoom, int currentZoom) {
final result = _tiles.values.toList()
..sort((a, b) => a
.zIndex(maxZoom, currentZoom)
.compareTo(b.zIndex(maxZoom, currentZoom)));

return result;
}
/// Returns in the order in which they should be rendered:
/// 1. Tiles at the current zoom.
/// 2. Tiles at the current zoom +/- 1.
/// 3. Tiles at the current zoom +/- 2.
/// 4. ...etc
List<TileImage> inRenderOrder(double maxZoom, int currentZoom) =>
_tiles.values.toList()
..sort((a, b) => a
.zIndex(maxZoom, currentZoom)
.compareTo(b.zIndex(maxZoom, currentZoom)));

// Creates missing tiles in the given range. Does not initiate loading of the
// tiles.
Expand Down
6 changes: 3 additions & 3 deletions lib/src/layer/tile_layer/tile_image_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class TileImageView {
final retain = Set<TileImage>.from(tilesInKeepRange);

for (final tile in tilesInKeepRange) {
if (!tile.readyToDisplay) {
if (!tile.transitionComplete) {
final coords = tile.coordinates;
if (!_retainAncestor(
retain, coords.x, coords.y, coords.z, coords.z - 5)) {
Expand Down Expand Up @@ -64,7 +64,7 @@ class TileImageView {

final tile = _tileImages[coords2.key];
if (tile != null) {
if (tile.readyToDisplay) {
if (tile.transitionComplete) {
retain.add(tile);
return true;
} else if (tile.loadFinishedAt != null) {
Expand Down Expand Up @@ -94,7 +94,7 @@ class TileImageView {

final tile = _tileImages[coords.key];
if (tile != null) {
if (tile.readyToDisplay || tile.loadFinishedAt != null) {
if (tile.transitionComplete || tile.loadFinishedAt != null) {
retain.add(tile);
}
}
Expand Down
55 changes: 41 additions & 14 deletions lib/src/layer/tile_layer/tile_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -514,11 +514,18 @@ class _TileLayerState extends State<TileLayer> with TickerProviderStateMixin {

_tileScaleCalculator.clearCacheUnlessZoomMatches(map.zoom);

final tileImagesInRenderOrder =
_tileImageManager.inRenderOrder(widget.maxZoom, tileZoom);

return _addBackgroundColor(
Stack(
children: [
if (widget.placeholderImage != null)
...visibleTileRange.coordinates.map(
..._placeholderCoordinates(
tileImagesInRenderOrder.takeWhile(
(tileImage) => tileImage.coordinates.z == tileZoom),
visibleTileRange,
).map(
(tileCoordinates) => TilePlaceholder(
key: ValueKey('placeholder-${tileCoordinates.key}'),
tileCoordinates: tileCoordinates,
Expand All @@ -530,25 +537,45 @@ class _TileLayerState extends State<TileLayer> with TickerProviderStateMixin {
placeholderImage: widget.placeholderImage!,
),
),
..._tileImageManager.inRenderOrder(widget.maxZoom, tileZoom).map(
(tileImage) => Tile(
// Must be an ObjectKey, not a ValueKey using the coordinates, in
// case we remove and replace the TileImage with a different one.
key: ObjectKey(tileImage),
scaledTileSize: _tileScaleCalculator.scaledTileSize(
map.zoom,
tileImage.coordinates.z,
),
currentPixelOrigin: currentPixelOrigin,
tileImage: tileImage,
tileBuilder: widget.tileBuilder,
),
...tileImagesInRenderOrder.map(
(tileImage) => Tile(
// Must be an ObjectKey, not a ValueKey using the coordinates, in
// case we remove and replace the TileImage with a different one.
key: ObjectKey(tileImage),
scaledTileSize: _tileScaleCalculator.scaledTileSize(
map.zoom,
tileImage.coordinates.z,
),
currentPixelOrigin: currentPixelOrigin,
tileImage: tileImage,
tileBuilder: widget.tileBuilder,
),
),
],
),
);
}

/// Returns the visible coordinates from the current zoom for which there is
/// no TileImage which has finished loading and transitioning. It is possible
/// a returned placeholder coordinate will be for a placeholder which is
/// obscured by a tile from a lower zoom or a collection of tiles from higher
/// zooms, this simple algorithm is a tradeoff between minimising the number
/// of unnecessary placeholders and keeping the calculation fast.
Iterable<TileCoordinates> _placeholderCoordinates(
Iterable<TileImage> tileImagesAtCurrentZoom,
DiscreteTileRange visibleTileRange,
) {
final obscuredCoordinatesAtCurrentZoom = tileImagesAtCurrentZoom
.where((tileImage) => tileImage.transitionComplete)
.map((tileImage) => tileImage.coordinates)
.toSet();

return visibleTileRange.coordinates
.toSet()
.difference(obscuredCoordinatesAtCurrentZoom);
}

// This can be removed once the deprecated backgroundColor option is removed.
Widget _addBackgroundColor(Widget child) {
// ignore: deprecated_member_use_from_same_package
Expand Down
10 changes: 5 additions & 5 deletions test/layer/tile_layer/tile_image_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ void main() {
test('ancestor tile is not stale if a tile has not loaded yet', () {
final tileImages = tileImagesMappingFrom([
MockTileImage(0, 0, 0),
MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false),
MockTileImage(0, 0, 1, loadFinished: false, transitionComplete: false),
]);
final removalState = TileImageView(
tileImages: tileImages,
Expand All @@ -75,8 +75,8 @@ void main() {
test('descendant tile is not stale if there is no loaded tile obscuring it',
() {
final tileImages = tileImagesMappingFrom([
MockTileImage(0, 0, 0, loadFinished: false, readyToDisplay: false),
MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false),
MockTileImage(0, 0, 0, loadFinished: false, transitionComplete: false),
MockTileImage(0, 0, 1, loadFinished: false, transitionComplete: false),
MockTileImage(0, 0, 2),
]);
final removalState = TileImageView(
Expand Down Expand Up @@ -171,7 +171,7 @@ void main() {

class MockTileImage extends TileImage {
@override
final bool readyToDisplay;
final bool transitionComplete;

@override
final bool loadError;
Expand All @@ -180,7 +180,7 @@ class MockTileImage extends TileImage {
int x,
int y,
int zoom, {
this.readyToDisplay = true,
this.transitionComplete = true,
bool loadFinished = true,
this.loadError = false,
void Function(TileCoordinates coordinates)? onLoadComplete,
Expand Down

0 comments on commit d5a6cb7

Please sign in to comment.