From 9bb2040db2bb5522a827a29f92d133f9d01ea780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Sat, 18 Jul 2026 23:30:06 +0300 Subject: [PATCH 1/2] fix: keep marker keys unique when repeated across worlds Closes #2229. When the world repeats horizontally, MarkerLayer draws each marker once per visible world; getPositioned passed key: m.key unchanged, so a keyed marker produced sibling Positioned widgets with the same key in the layer's Stack (Duplicate keys). Keep the caller's key on the main world (worldShift == 0) and derive ValueKey((m.key, worldShift)) for repeated copies, so find.byKey and per-marker identity are unchanged. --- lib/src/layer/marker_layer/marker_layer.dart | 8 ++- test/layer/marker_key_worlds_test.dart | 63 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 test/layer/marker_key_worlds_test.dart diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index 0e18c700b..fd4cec36d 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -138,7 +138,13 @@ class _MarkerLayerState extends State { Offset(shiftedX, pxPoint.dy) - pixelOrigin; return Positioned( - key: m.key, + // Keep the caller's key on the main world so find.byKey and + // per-marker identity are unchanged; derive a distinct key for + // each repeated world so a keyed marker does not produce + // duplicate sibling keys in the Stack (#2229). + key: m.key == null || worldShift == 0 + ? m.key + : ValueKey((m.key, worldShift)), width: m.width, height: m.height, left: shiftedLocalPoint.dx - right, diff --git a/test/layer/marker_key_worlds_test.dart b/test/layer/marker_key_worlds_test.dart new file mode 100644 index 000000000..1a272e24f --- /dev/null +++ b/test/layer/marker_key_worlds_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import '../test_utils/test_tile_provider.dart'; + +void main() { + testWidgets( + 'a keyed marker repeated across worlds does not produce duplicate keys (#2229)', + (tester) async { + const key = Key('m-1'); + final markers = [ + const Marker( + key: key, + width: 20, + height: 20, + point: LatLng(0, 0), + child: FlutterLogo(), + ), + ]; + + // A wide viewport at low zoom shows the world several times, so a keyed + // marker is drawn once per visible world. Before the fix, those copies + // shared m.key and the layer's Stack threw "Duplicate keys". + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 900, + height: 200, + child: FlutterMap( + options: const MapOptions( + initialCenter: LatLng(0, 0), + initialZoom: 0, + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + tileProvider: TestTileProvider(), + ), + MarkerLayer(markers: markers), + ], + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // The marker really is drawn more than once (worlds repeated). + expect( + tester.widgetList(find.byType(FlutterLogo)).length, + greaterThan(1), + ); + // The main world keeps the caller's key, so find.byKey resolves to one. + expect(find.byKey(key), findsOneWidget); + }, + ); +} From e0d429da116b95451abc480ae265cf91d7a65d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0hsan=20G=C3=B6rgel?= Date: Tue, 13 Oct 2026 22:57:23 +0300 Subject: [PATCH 2/2] fix: key repeated-world marker copies by world index so they survive zooming --- lib/src/layer/marker_layer/marker_layer.dart | 6 +- test/layer/marker_key_worlds_test.dart | 73 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index 0dd9d88e3..3ef884f3f 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -236,10 +236,12 @@ class _MarkerLayerState extends State { // Keep the caller's key on the main world so find.byKey and // per-marker identity are unchanged; derive a distinct key for // each repeated world so a keyed marker does not produce - // duplicate sibling keys in the Stack (#2229). + // duplicate sibling keys in the Stack (#2229). The key uses the + // world index rather than the pixel shift, which changes with + // zoom, so a copy keeps its element (and state) while zooming. key: m.key == null || worldShift == 0 ? m.key - : ValueKey((m.key, worldShift)), + : ValueKey((m.key, (worldShift / worldWidth).round())), width: width, height: height, left: shiftedLocalPoint.dx - right, diff --git a/test/layer/marker_key_worlds_test.dart b/test/layer/marker_key_worlds_test.dart index 1a272e24f..428c7fc33 100644 --- a/test/layer/marker_key_worlds_test.dart +++ b/test/layer/marker_key_worlds_test.dart @@ -60,4 +60,77 @@ void main() { expect(find.byKey(key), findsOneWidget); }, ); + + testWidgets( + 'a keyed marker copy in a repeated world keeps its state across zoom', + (tester) async { + final controller = MapController(); + final markers = [ + const Marker( + key: Key('m-1'), + width: 20, + height: 20, + point: LatLng(0, 0), + child: _StatefulMarker(), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 900, + height: 200, + child: FlutterMap( + mapController: controller, + options: const MapOptions( + initialCenter: LatLng(0, 0), + initialZoom: 0, + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + tileProvider: TestTileProvider(), + ), + MarkerLayer(markers: markers), + ], + ), + ), + ), + ), + ), + ); + + final before = tester + .stateList<_StatefulMarkerState>(find.byType(_StatefulMarker)) + .toList(); + // The main world and at least one repeated world are drawn. + expect(before.length, greaterThan(1)); + + // Zooming changes the world width in pixels, but each copy stays in the + // same world, so its element and state must be kept. + controller.move(const LatLng(0, 0), 0.5); + await tester.pump(); + + final after = tester + .stateList<_StatefulMarkerState>(find.byType(_StatefulMarker)) + .toList(); + expect(tester.takeException(), isNull); + expect(after, orderedEquals(before)); + }, + ); +} + +class _StatefulMarker extends StatefulWidget { + const _StatefulMarker(); + + @override + State<_StatefulMarker> createState() => _StatefulMarkerState(); +} + +class _StatefulMarkerState extends State<_StatefulMarker> { + @override + Widget build(BuildContext context) => const FlutterLogo(); }