diff --git a/example/lib/caching_test.dart b/example/lib/caching_test.dart index dcc8c50..c65f8b3 100644 --- a/example/lib/caching_test.dart +++ b/example/lib/caching_test.dart @@ -20,7 +20,12 @@ class _CachingTestAppState extends State { for (int i = 0; i < 20; i++) { controller.addChild( Offset((i % 5) * 120.0, (i ~/ 5) * 120.0), - BuildCounterWidget(color: Colors.primaries[i % Colors.primaries.length], label: 'Widget $i'), + BuildCounterWidget( + key: ValueKey(i), // since in the tree it will be just an array at the same height, a count change + // along the egdes of screen build all so you need a key to identify individually + color: Colors.primaries[i % Colors.primaries.length], + label: 'Widget $i', + ), ); } } diff --git a/example/lib/dynamic_widget_example.dart b/example/lib/dynamic_widget_example.dart index 9df3dac..ac3a2d0 100644 --- a/example/lib/dynamic_widget_example.dart +++ b/example/lib/dynamic_widget_example.dart @@ -24,14 +24,13 @@ class _DynamicWidgetExampleState extends State { } void _setupWidgets() { - // Approach 1: Using StatefulWidget with GlobalKey to preserve state - controller.addChild(const Offset(0, 0), SelfManagedCounterWidget(key: GlobalKey(), label: "Self-Managed")); + // Approach 1: Using StatefulWidget with state inside the widget getting lost if unmounted + controller.addChild(const Offset(0, 0), SelfManagedCounterWidget(label: "Self-Managed")); - // Approach 2: Using updateChildWidget, though since before and after it's still just as the same ExternalDataWidget - // you need to have the key change as an arg change is separated from the tree + // Approach 2: Using updateChildWidget, you need this since the children dep tree is direcly managed by the controller controller.addChild( const Offset(200, 0), - ExternalDataWidget(key: ValueKey('external_$counter'), counter: counter, message: message, color: selectedColor), + ExternalDataWidget(counter: counter, message: message, color: selectedColor), ); // Approach 3: Using ValueListenableBuilder for reactive updates @@ -74,7 +73,7 @@ class _DynamicWidgetExampleState extends State { controller.updateChildWidget( 1, // Widget ID (second widget added) ExternalDataWidget( - key: ValueKey('external_$counter'), // Force rebuild with unique key + // key: ValueKey('external_$counter'), // Force rebuild with unique key counter: counter, message: message, color: selectedColor, @@ -98,9 +97,9 @@ class _DynamicWidgetExampleState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Four approaches for dynamic inputs:', style: TextStyle(fontWeight: FontWeight.bold)), + const Text('Three approaches for dynamic inputs:', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), - const Text('1. Self-managed StatefulWidget with GlobalKey - preserves state when off-screen'), + const Text('1. Self-managed StatefulWidget (left), loses state on unmount'), const Text('2. External data + updateChildWidget (center) - manual updates'), const Text('3. ValueListenableBuilder (right) - reactive updates'), const SizedBox(height: 16), diff --git a/example/lib/main.dart b/example/lib/main.dart index 7b15969..bd3b17b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,4 @@ -import './app.dart'; +import 'simple_example.dart'; import './caching_test.dart'; import './dynamic_widget_example.dart'; import 'package:flutter/material.dart'; @@ -27,9 +27,9 @@ class ExampleChooser extends StatelessWidget { children: [ ElevatedButton( onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const App())); + Navigator.push(context, MaterialPageRoute(builder: (context) => const SimpleExample())); }, - child: const Text('Original Example'), + child: const Text('Simple Example'), ), const SizedBox(height: 16), ElevatedButton( @@ -50,8 +50,8 @@ class ExampleChooser extends StatelessWidget { padding: EdgeInsets.all(16.0), child: Text( 'Examples:\n' - '• Original: Basic usage demo\n' - '• Caching Test: Shows Flutter\'s automatic widget optimization\n' + '• Simple: Basic usage demo\n' + '• Caching Test: To sanity check build counts\n' '• Dynamic Inputs: How to handle changing widget data', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: Colors.grey), diff --git a/example/lib/app.dart b/example/lib/simple_example.dart similarity index 84% rename from example/lib/app.dart rename to example/lib/simple_example.dart index 6c443a4..f1fd200 100644 --- a/example/lib/app.dart +++ b/example/lib/simple_example.dart @@ -3,14 +3,14 @@ import 'package:infinite_lazy_grid/infinite_lazy_grid.dart'; import 'widgets/fps.dart'; -class App extends StatefulWidget { - const App({super.key}); +class SimpleExample extends StatefulWidget { + const SimpleExample({super.key}); @override - State createState() => _AppState(); + State createState() => _SimpleExampleState(); } -class _AppState extends State { +class _SimpleExampleState extends State { final LazyCanvasController controller = LazyCanvasController(debug: true); @override @@ -33,17 +33,20 @@ class _AppState extends State { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar(title: const Text('Simple Example')), floatingActionButton: Column( mainAxisSize: MainAxisSize.min, spacing: 8, children: [ FloatingActionButton( + heroTag: 'app_zoom_in', onPressed: () { controller.updateScalebyDelta(0.1); }, child: const Icon(Icons.zoom_in), ), FloatingActionButton( + heroTag: 'app_zoom_out', onPressed: () { controller.updateScalebyDelta(-0.1); }, diff --git a/example/lib/widgets/fps.dart b/example/lib/widgets/fps.dart index f28a7ab..1062c3f 100644 --- a/example/lib/widgets/fps.dart +++ b/example/lib/widgets/fps.dart @@ -22,9 +22,11 @@ class _FpsState extends State { double elapsedSeconds = (elapsedMicroseconds - _lastFrameTime) * 1e-6; if (elapsedSeconds != 0) { _lastFrameTime = elapsedMicroseconds; - setState(() { - _frameRate = '${(1.0 / elapsedSeconds).toStringAsFixed(2)} fps'; - }); + if (mounted) { + setState(() { + _frameRate = '${(1.0 / elapsedSeconds).toStringAsFixed(2)} fps'; + }); + } } // redraw if mounted if (mounted) { diff --git a/lib/core/background.dart b/lib/core/background.dart index 17f9397..fe58704 100644 --- a/lib/core/background.dart +++ b/lib/core/background.dart @@ -9,25 +9,28 @@ abstract class CanvasBackground { /// draw the backround on this context. Implement this to have /// different kinds of backgrounds - void paint(Canvas canvas, Offset offset, double scale, Size canvasSize); + /// [screenOffset] is the screen space offset for clipping + /// [canvasOffset] is the grid space offset from the controller + void paint(Canvas canvas, Offset screenOffset, Offset canvasOffset, double scale, Size canvasSize); } class NoBackground extends CanvasBackground { const NoBackground(); @override - void paint(Canvas canvas, Offset offset, double scale, Size canvasSize) {} + void paint(Canvas canvas, Offset screenOffset, Offset canvasOffset, double scale, Size canvasSize) {} } class SingleColorBackround extends CanvasBackground { const SingleColorBackround(Color backgroundColor) : super(bgColor: backgroundColor); @override - void paint(Canvas canvas, Offset offset, double scale, Size canvasSize) { + void paint(Canvas canvas, Offset screenOffset, Offset canvasOffset, double scale, Size canvasSize) { final paint = Paint() ..color = bgColor ..style = PaintingStyle.fill; - canvas.drawRect(Rect.fromLTWH(0, 0, canvasSize.width, canvasSize.height), paint); + + canvas.drawRect(Rect.fromLTWH(screenOffset.dx, screenOffset.dy, canvasSize.width, canvasSize.height), paint); } } @@ -39,18 +42,33 @@ class DotGridBackround extends CanvasBackground { const DotGridBackround({this.size = 2.0, this.spacing = 50.0, this.color = Colors.black12}) : super(); @override - void paint(Canvas canvas, Offset offset, double scale, Size canvasSize) { + void paint(Canvas canvas, Offset screenOffset, Offset canvasOffset, double scale, Size canvasSize) { final paint = Paint() ..color = color ..strokeWidth = 1.0 * scale; double scaledSpacing = spacing * scale; double scaledSize = size * scale; - double startX = (offset.dx % scaledSpacing) - scaledSpacing; - double startY = (offset.dy % scaledSpacing) - scaledSpacing; + // Use canvasOffset to determine the grid position in grid space + // Calculate the grid origin in canvas space (0,0 should be at a grid point) + // Find the first grid point that aligns with the coordinate system + double firstGridX = (canvasOffset.dx / spacing).floor() * spacing; + double firstGridY = (canvasOffset.dy / spacing).floor() * spacing; - for (double x = startX; x < canvasSize.width + scaledSpacing; x += scaledSpacing) { - for (double y = startY; y < canvasSize.height + scaledSpacing; y += scaledSpacing) { + // Convert grid space coordinates to screen space + double screenStartX = (firstGridX - canvasOffset.dx) * scale + screenOffset.dx; + double screenStartY = (firstGridY - canvasOffset.dy) * scale + screenOffset.dy; + + // Ensure we start drawing before the visible area to avoid gaps + while (screenStartX > screenOffset.dx) { + screenStartX -= scaledSpacing; + } + while (screenStartY > screenOffset.dy) { + screenStartY -= scaledSpacing; + } + + for (double x = screenStartX; x < screenOffset.dx + canvasSize.width + scaledSpacing; x += scaledSpacing) { + for (double y = screenStartY; y < screenOffset.dy + canvasSize.height + scaledSpacing; y += scaledSpacing) { canvas.drawCircle(Offset(x, y), scaledSize, paint); } } diff --git a/lib/core/controller/controller.dart b/lib/core/controller/controller.dart index f8d5580..96ad5e2 100644 --- a/lib/core/controller/controller.dart +++ b/lib/core/controller/controller.dart @@ -24,6 +24,7 @@ class LazyCanvasController with ChangeNotifier { final Offset? _buildCacheExtent; late Offset _buildExtent; final Size _hashCellSize; + bool _adhocRender = false; bool _init = false; late final SpatialHashing _spatialHash; List _renderedChildrenCache = []; @@ -46,7 +47,8 @@ class LazyCanvasController with ChangeNotifier { Size get canvasSize => _canvasSize; Offset get _ssCenter => Offset(_canvasSize.width / 2, _canvasSize.height / 2); Offset get _gsCenter => ssToGs(_ssCenter, _gsTopLeftOffset, _scale); - bool get _cleanRenderState => _init && _lastProcessedOffset == _gsTopLeftOffset && _lastProcessedScale == _scale; + bool get _cleanRenderState => + _init && _lastProcessedOffset == _gsTopLeftOffset && _lastProcessedScale == _scale && !_adhocRender; // ==================== Callback Functions ==================== @@ -124,6 +126,7 @@ class LazyCanvasController with ChangeNotifier { // Create a new _ChildInfo with the new widget _children[id] = _ChildInfo(gsPosition: child.gsPosition, widget: newWidget) ..lastRenderedSize = child.lastRenderedSize; + _adhocRender = true; // so that even if the position is same, it will be re-rendered notifyListeners(); } else { throw _ChildNotFoundException; @@ -190,6 +193,7 @@ class LazyCanvasController with ChangeNotifier { _lastProcessedOffset = _gsTopLeftOffset; _lastProcessedScale = _scale; + _adhocRender = false; final idsToBuild = _childrenWithinBuildArea(_gsCenter, _buildExtent); diff --git a/lib/core/render.dart b/lib/core/render.dart index e0abadd..1d61e59 100644 --- a/lib/core/render.dart +++ b/lib/core/render.dart @@ -49,6 +49,7 @@ class _LazyCanvasState extends State with TickerProviderStateMixin childrenIds; final List ssPositions; final double scale; + final Offset gridSpaceOffset; final CanvasBackground canvasBackground; final Function onCanvasSizeChange; final Function onChildSizeChange; @@ -91,6 +93,7 @@ class _CanvasRenderObject extends MultiChildRenderObjectWidget { required this.childrenIds, required this.ssPositions, required this.scale, + required this.gridSpaceOffset, required this.canvasBackground, required this.onCanvasSizeChange, required this.onChildSizeChange, @@ -104,6 +107,7 @@ class _CanvasRenderObject extends MultiChildRenderObjectWidget { childrenIds: childrenIds, ssPositions: ssPositions, scale: scale, + gridSpaceOffset: gridSpaceOffset, canvasBackground: canvasBackground, onCanvasSizeChange: onCanvasSizeChange, onChildSizeChange: onChildSizeChange, @@ -116,6 +120,7 @@ class _CanvasRenderObject extends MultiChildRenderObjectWidget { ..childrenIds = childrenIds ..ssPositions = ssPositions ..canvasBackground = canvasBackground + ..gridSpaceOffset = gridSpaceOffset ..scale = scale; } } @@ -133,6 +138,7 @@ class _CanvasRenderBox extends RenderBox CanvasBackground _canvasBackground; List _childrenIds; List _ssPositions; + Offset _gridSpaceOffset; double _scale; Function onCanvasSizeChange; Function onChildSizeChange; @@ -141,6 +147,7 @@ class _CanvasRenderBox extends RenderBox required childrenIds, required ssPositions, required scale, + required gridSpaceOffset, required canvasBackground, required this.onCanvasSizeChange, required this.onChildSizeChange, @@ -148,6 +155,7 @@ class _CanvasRenderBox extends RenderBox _childrenIds = childrenIds, _ssPositions = ssPositions, _scale = scale, + _gridSpaceOffset = gridSpaceOffset, _canvasBackground = canvasBackground; @override @@ -180,6 +188,13 @@ class _CanvasRenderBox extends RenderBox } } + set gridSpaceOffset(Offset gridSpaceOffset) { + if (_gridSpaceOffset != gridSpaceOffset) { + _gridSpaceOffset = gridSpaceOffset; + markNeedsPaint(); + } + } + set canvasBackground(CanvasBackground canvasBackground) { if (_canvasBackground != canvasBackground) { _canvasBackground = canvasBackground; @@ -215,8 +230,13 @@ class _CanvasRenderBox extends RenderBox @override void paint(PaintingContext context, Offset canvasStartOffset) { assert(childCount == _ssPositions.length); + + // Clip to bounds before any painting, due to extent cache you may get the point ouside bounds + context.canvas.save(); + context.canvas.clipRect(canvasStartOffset & size); + // use the canvas background painter, pass it the canvas and that should handle drawing the background - _canvasBackground.paint(context.canvas, canvasStartOffset, _scale, size); + _canvasBackground.paint(context.canvas, canvasStartOffset, _gridSpaceOffset, _scale, size); // though using ssPositionns here directly worked for me but docs using the parentData // to get this info is the convention as child can be reordered ( though this will always @@ -231,11 +251,13 @@ class _CanvasRenderBox extends RenderBox final drawAt = canvasStartOffset + childParentData.offset; context.canvas.translate(drawAt.dx, drawAt.dy); context.canvas.scale(childParentData.scale, childParentData.scale); - context.paintChild(child, Offset.zero); // pain at 0 as already translated + context.paintChild(child, Offset.zero); // paint at 0 as already translated context.canvas.restore(); child = childParentData.nextSibling; } + + context.canvas.restore(); // clip restore } @override diff --git a/test/core/spatial_hashing_test.dart b/test/core/spatial_hashing_test.dart index 7ef1c3b..c096783 100644 --- a/test/core/spatial_hashing_test.dart +++ b/test/core/spatial_hashing_test.dart @@ -16,7 +16,8 @@ void main() { spatialHashing.add(point, 'A'); final results = spatialHashing.getPointsAround(point, const Offset(0, 0)); expect(results.length, 1); - expect(results.first, (point, 'A')); + expect(results.first.point, point); + expect(results.first.data, 'A'); }); test('remove point', () { @@ -34,8 +35,8 @@ void main() { spatialHashing.add(p2, 'B'); final results = spatialHashing.getPointsAround(p1, const Offset(0, 0)); expect(results.length, 2); - expect(results, contains((p1, 'A'))); - expect(results, contains((p2, 'B'))); + expect(results.any((pointData) => pointData.point == p1 && pointData.data == 'A'), isTrue); + expect(results.any((pointData) => pointData.point == p2 && pointData.data == 'B'), isTrue); }); test('add points in different cells and query with offset', () { @@ -46,8 +47,8 @@ void main() { // Query with offset to include both cells final results = spatialHashing.getPointsAround(p1, const Offset(10, 10)); expect(results.length, 2); - expect(results, contains((p1, 'A'))); - expect(results, contains((p2, 'B'))); + expect(results.any((pointData) => pointData.point == p1 && pointData.data == 'A'), isTrue); + expect(results.any((pointData) => pointData.point == p2 && pointData.data == 'B'), isTrue); }); test('removing one of multiple points in a cell', () { @@ -58,7 +59,8 @@ void main() { spatialHashing.remove(p1); final results = spatialHashing.getPointsAround(p2, const Offset(0, 0)); expect(results.length, 1); - expect(results.first, (p2, 'B')); + expect(results.first.point, p2); + expect(results.first.data, 'B'); }); }); }