TileView freezes during continuous position updates while markers keep moving (stale mapPosition in TileJobQueue)
Package: mapsforge_flutter 4.0.0 (latest published release) File (in 4.0.0): lib/src/tile/tile_job_queue.dart
Note: I can see the tile pipeline on master has since been refactored (TileJobQueue now extends ChangeNotifier; TileView reads mapModel.lastPosition via ListenableBuilder and passes it straight to TransformWidget, rather than deriving the transform from the emitted TileSet.mapPosition). That change looks like it would structurally fix the freeze described below, since the pan offset would no longer depend on a possibly-stale TileSet. Could you confirm whether this is already resolved on master, and if so roughly when a release including it is planned? The report below is against the currently published 4.0.0, where it reproduces.
Summary
When the map position is updated continuously at high frequency (e.g. a camera-follow that calls MapModel.moveTo() on every frame), the tile layer stops panning while a MarkerDatastoreOverlay on the same MapModel keeps tracking the position correctly. Visually: the basemap tiles freeze in place, but the markers slide across the frozen tiles.
The root cause is that, for a pan that stays within the currently cached tile dimension, TileJobQueue re-emits a TileSet that still carries the old mapPosition captured when the job was created (or emits nothing at all while a job is still running). TransformWidget derives its pan offset from tileSet.mapPosition, so a stale mapPosition means the tiles are drawn at the old translation.
Reproduction
Create an offline MapModel and a MapsforgeView.
Add a MarkerDatastoreOverlay with one marker at a fixed lat/lng.
Drive the position programmatically, e.g. interpolate between two nearby points and call mapModel.moveTo(lat, lng) ~20×/second (a typical camera-follow loop).
Expected: tiles and marker pan together smoothly. Actual: tiles freeze; the marker keeps moving over the static tiles. When the in-flight tile job finishes, the tiles jump to a position that lags the current one.
(The two layers diverge because MarkerDatastoreOverlay subscribes to mapModel.positionStream directly and repaints per event, whereas the tile layer's position comes from TileSet.mapPosition, which is not refreshed on these events.)
Root cause
In the positionStream listener there are three branches. The rotation / scaling branch correctly rebuilds the TileSet with the new position:
dart
if (_currentJob?.tileSet.mapPosition.latitude == position.latitude &&
_currentJob?.tileSet.mapPosition.longitude == position.longitude &&
_currentJob?.tileSet.mapPosition.zoomlevel == position.zoomlevel &&
_currentJob?.tileSet.mapPosition.indoorLevel == position.indoorLevel) {
// do not recalculate for rotation or scaling
TileSet tileSet = TileSet(center: _currentJob!.tileSet.center, mapPosition: position);
tileSet.images.addEntries(_currentJob!.tileSet.images.entries);
_currentJob = _CurrentJob(_currentJob!.tileDimension, tileSet);
_emitTileSetBatched(_currentJob!.tileSet); // <-- rebased onto position (correct)
return;
}
But the very next branch — a pan that stays inside the same tile dimension (same tiles needed, only the offset changed) — does not perform the same rebase:
dart
TileDimension tileDimension =
TileHelper.calculateTiles(mapViewPosition: position, screensize: _size!);
if (_currentJob?.tileDimension.contains(tileDimension) ?? false) {
if (_currentJob!._done) {
_emitTileSetBatched(_currentJob!.tileSet); // <-- STALE mapPosition (bug)
} else {
// same information to draw, previous job is still running
return; // <-- no emit while running (bug)
}
}
So during a continuous small pan:
while a job is still running → the listener returns with no emission, so the pan offset is never updated even though all needed tiles are already cached;
after the job is _done → it re-emits _currentJob!.tileSet, whose mapPosition is the one from when that job started, not the current one.
TransformWidget computes its translate from that mapPosition:
dart
Mappoint centerPosition = mapPosition.getCenter();
...
Transform.translate(
offset: Offset(mapCenter.x - centerPosition.x, mapCenter.y - centerPosition.y),
child: child,
)
A stale mapPosition ⇒ stale offset ⇒ frozen tiles.
Suggested fix (for 4.0.0 — likely already handled by the master refactor)
If the master rewrite already fixes this, please disregard. For the 4.0.0 line of code: give the pan-within-dimension branch the same rebase the rotation/scaling branch already does — for both the running and the done case, since the tiles for this dimension are already (at least partially) available:
dart
if (_currentJob?.tileDimension.contains(tileDimension) ?? false) {
// Tiles for this dimension are already available; only the pan offset
// changed. Re-emit rebased onto the NEW position so the view keeps
// panning (rather than freezing) whether or not the render is finished.
TileSet tileSet = TileSet(
center: _currentJob!.tileSet.center,
mapPosition: position,
);
tileSet.images.addEntries(_currentJob!.tileSet.images.entries);
if (_currentJob!._done) {
_currentJob = _CurrentJob(_currentJob!.tileDimension, tileSet);
}
_emitTileSetBatched(tileSet);
return;
}
Alternatively (a more defensive one-liner), rebase at the single emission choke point so no code path can emit a stale position:
dart
void _emitTileSetBatched(TileSet tileSet) {
final MapPosition? latest = mapModel.lastPosition;
if (latest != null && !identical(tileSet.mapPosition, latest)) {
final rebased = TileSet(center: tileSet.center, mapPosition: latest);
rebased.images.addEntries(tileSet.images.entries);
tileSet = rebased;
}
_batchTileset = tileSet;
_batchTimer ??= Timer(const Duration(milliseconds: 16), () { ... });
}
If it isn't already fixed on master, I'm happy to open a PR against the current code with whichever approach you prefer.
Environment
mapsforge_flutter: 4.0.0
Flutter: stable (3.3x)
Platforms observed: iOS Simulator + macOS (pure-Dart path, not platform-specific)
TileView freezes during continuous position updates while markers keep moving (stale mapPosition in TileJobQueue)
Package: mapsforge_flutter 4.0.0 (latest published release) File (in 4.0.0): lib/src/tile/tile_job_queue.dart
Note: I can see the tile pipeline on master has since been refactored (TileJobQueue now extends ChangeNotifier; TileView reads mapModel.lastPosition via ListenableBuilder and passes it straight to TransformWidget, rather than deriving the transform from the emitted TileSet.mapPosition). That change looks like it would structurally fix the freeze described below, since the pan offset would no longer depend on a possibly-stale TileSet. Could you confirm whether this is already resolved on master, and if so roughly when a release including it is planned? The report below is against the currently published 4.0.0, where it reproduces.
Summary
When the map position is updated continuously at high frequency (e.g. a camera-follow that calls MapModel.moveTo() on every frame), the tile layer stops panning while a MarkerDatastoreOverlay on the same MapModel keeps tracking the position correctly. Visually: the basemap tiles freeze in place, but the markers slide across the frozen tiles.
The root cause is that, for a pan that stays within the currently cached tile dimension, TileJobQueue re-emits a TileSet that still carries the old mapPosition captured when the job was created (or emits nothing at all while a job is still running). TransformWidget derives its pan offset from tileSet.mapPosition, so a stale mapPosition means the tiles are drawn at the old translation.
Reproduction
Create an offline MapModel and a MapsforgeView.
Add a MarkerDatastoreOverlay with one marker at a fixed lat/lng.
Drive the position programmatically, e.g. interpolate between two nearby points and call mapModel.moveTo(lat, lng) ~20×/second (a typical camera-follow loop).
Expected: tiles and marker pan together smoothly. Actual: tiles freeze; the marker keeps moving over the static tiles. When the in-flight tile job finishes, the tiles jump to a position that lags the current one.
(The two layers diverge because MarkerDatastoreOverlay subscribes to mapModel.positionStream directly and repaints per event, whereas the tile layer's position comes from TileSet.mapPosition, which is not refreshed on these events.)
Root cause
In the positionStream listener there are three branches. The rotation / scaling branch correctly rebuilds the TileSet with the new position:
dart
if (_currentJob?.tileSet.mapPosition.latitude == position.latitude &&
_currentJob?.tileSet.mapPosition.longitude == position.longitude &&
_currentJob?.tileSet.mapPosition.zoomlevel == position.zoomlevel &&
_currentJob?.tileSet.mapPosition.indoorLevel == position.indoorLevel) {
// do not recalculate for rotation or scaling
TileSet tileSet = TileSet(center: _currentJob!.tileSet.center, mapPosition: position);
tileSet.images.addEntries(_currentJob!.tileSet.images.entries);
_currentJob = _CurrentJob(_currentJob!.tileDimension, tileSet);
_emitTileSetBatched(_currentJob!.tileSet); // <-- rebased onto
position(correct)return;
}
But the very next branch — a pan that stays inside the same tile dimension (same tiles needed, only the offset changed) — does not perform the same rebase:
dart
TileDimension tileDimension =
TileHelper.calculateTiles(mapViewPosition: position, screensize: _size!);
if (_currentJob?.tileDimension.contains(tileDimension) ?? false) {
if (_currentJob!._done) {
_emitTileSetBatched(_currentJob!.tileSet); // <-- STALE mapPosition (bug)
} else {
// same information to draw, previous job is still running
return; // <-- no emit while running (bug)
}
}
So during a continuous small pan:
while a job is still running → the listener returns with no emission, so the pan offset is never updated even though all needed tiles are already cached;
after the job is _done → it re-emits _currentJob!.tileSet, whose mapPosition is the one from when that job started, not the current one.
TransformWidget computes its translate from that mapPosition:
dart
Mappoint centerPosition = mapPosition.getCenter();
...
Transform.translate(
offset: Offset(mapCenter.x - centerPosition.x, mapCenter.y - centerPosition.y),
child: child,
)
A stale mapPosition ⇒ stale offset ⇒ frozen tiles.
Suggested fix (for 4.0.0 — likely already handled by the master refactor)
If the master rewrite already fixes this, please disregard. For the 4.0.0 line of code: give the pan-within-dimension branch the same rebase the rotation/scaling branch already does — for both the running and the done case, since the tiles for this dimension are already (at least partially) available:
dart
if (_currentJob?.tileDimension.contains(tileDimension) ?? false) {
// Tiles for this dimension are already available; only the pan offset
// changed. Re-emit rebased onto the NEW position so the view keeps
// panning (rather than freezing) whether or not the render is finished.
TileSet tileSet = TileSet(
center: _currentJob!.tileSet.center,
mapPosition: position,
);
tileSet.images.addEntries(_currentJob!.tileSet.images.entries);
if (_currentJob!._done) {
_currentJob = _CurrentJob(_currentJob!.tileDimension, tileSet);
}
_emitTileSetBatched(tileSet);
return;
}
Alternatively (a more defensive one-liner), rebase at the single emission choke point so no code path can emit a stale position:
dart
void _emitTileSetBatched(TileSet tileSet) {
final MapPosition? latest = mapModel.lastPosition;
if (latest != null && !identical(tileSet.mapPosition, latest)) {
final rebased = TileSet(center: tileSet.center, mapPosition: latest);
rebased.images.addEntries(tileSet.images.entries);
tileSet = rebased;
}
_batchTileset = tileSet;
_batchTimer ??= Timer(const Duration(milliseconds: 16), () { ... });
}
If it isn't already fixed on master, I'm happy to open a PR against the current code with whichever approach you prefer.
Environment
mapsforge_flutter: 4.0.0
Flutter: stable (3.3x)
Platforms observed: iOS Simulator + macOS (pure-Dart path, not platform-specific)