From 7ad54a92cbc56ff6267bee994660f2a1ee7acab2 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sat, 26 Jul 2025 17:47:42 +0000 Subject: [PATCH] fix more than needed rebuilds on canvas children this used a stupid builderFunc instead of a widget so flutter wasn't able to optimise those widget builds and it was simply a blanket rebuild. Added more examples as well --- example/lib/app.dart | 2 +- example/lib/caching_test.dart | 86 +++++++ example/lib/dynamic_widget_example.dart | 219 ++++++++++++++++++ example/lib/main.dart | 54 ++++- example/lib/widgets/build_counter_widget.dart | 45 ++++ lib/core/controller/controller.dart | 23 +- lib/core/controller/types.dart | 4 +- test/canvas_view_test.dart | 6 +- 8 files changed, 427 insertions(+), 12 deletions(-) create mode 100644 example/lib/caching_test.dart create mode 100644 example/lib/dynamic_widget_example.dart create mode 100644 example/lib/widgets/build_counter_widget.dart diff --git a/example/lib/app.dart b/example/lib/app.dart index e83db05..6c443a4 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -19,7 +19,7 @@ class _AppState extends State { for (int i = 0; i < 10; i++) { controller.addChild( Offset((i % 80) * 100.0, (i ~/ 80) * 100.0), - (_) => GestureDetector( + GestureDetector( onTap: () { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Tapped a container'))); controller.focusOnChild(context, i); diff --git a/example/lib/caching_test.dart b/example/lib/caching_test.dart new file mode 100644 index 0000000..dcc8c50 --- /dev/null +++ b/example/lib/caching_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_lazy_grid/infinite_lazy_grid.dart'; +import 'widgets/build_counter_widget.dart'; + +class CachingTestApp extends StatefulWidget { + const CachingTestApp({super.key}); + + @override + State createState() => _CachingTestAppState(); +} + +class _CachingTestAppState extends State { + final LazyCanvasController controller = LazyCanvasController(debug: false); + + @override + void initState() { + super.initState(); + + // Add some test widgets + 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'), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Widget Caching Test'), + actions: [ + IconButton( + onPressed: () { + // Force a rebuild by calling notifyListeners + // In this new approach, Flutter handles the caching + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Flutter automatically handles widget caching - no manual invalidation needed!'), + ), + ); + }, + icon: const Icon(Icons.refresh), + tooltip: 'Flutter Auto-Caching Info', + ), + ], + ), + body: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + color: Colors.yellow[100], + child: const Text( + 'Instructions:\n' + '• Pan around the canvas - widgets should only build once initially\n' + '• Watch the console for build messages\n' + '• Use the refresh button to force rebuilds\n' + '• Zoom in/out to see caching in action', + style: TextStyle(fontSize: 12), + ), + ), + Expanded( + child: LazyCanvas(controller: controller, canvasBackground: const DotGridBackround()), + ), + ], + ), + floatingActionButton: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + heroTag: 'zoom_in', + onPressed: () => controller.updateScalebyDelta(0.1), + child: const Icon(Icons.zoom_in), + ), + const SizedBox(height: 8), + FloatingActionButton( + heroTag: 'zoom_out', + onPressed: () => controller.updateScalebyDelta(-0.1), + child: const Icon(Icons.zoom_out), + ), + ], + ), + ); + } +} diff --git a/example/lib/dynamic_widget_example.dart b/example/lib/dynamic_widget_example.dart new file mode 100644 index 0000000..9df3dac --- /dev/null +++ b/example/lib/dynamic_widget_example.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_lazy_grid/infinite_lazy_grid.dart'; + +/// Demo showing different approaches to handle dynamic widget inputs +class DynamicWidgetExample extends StatefulWidget { + const DynamicWidgetExample({super.key}); + + @override + State createState() => _DynamicWidgetExampleState(); +} + +class _DynamicWidgetExampleState extends State { + final LazyCanvasController controller = LazyCanvasController(debug: false); + + // Simulate some dynamic data + int counter = 0; + String message = "Hello World"; + Color selectedColor = Colors.blue; + + @override + void initState() { + super.initState(); + _setupWidgets(); + } + + void _setupWidgets() { + // Approach 1: Using StatefulWidget with GlobalKey to preserve state + controller.addChild(const Offset(0, 0), SelfManagedCounterWidget(key: GlobalKey(), 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 + controller.addChild( + const Offset(200, 0), + ExternalDataWidget(key: ValueKey('external_$counter'), counter: counter, message: message, color: selectedColor), + ); + + // Approach 3: Using ValueListenableBuilder for reactive updates + controller.addChild( + const Offset(400, 0), + ValueListenableBuilder( + valueListenable: _counterNotifier, + builder: (context, count, child) { + return Container( + width: 120, + height: 80, + decoration: BoxDecoration( + color: Colors.green, + border: Border.all(color: Colors.black, width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + 'Reactive: $count', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ); + }, + ), + ); + } + + final ValueNotifier _counterNotifier = ValueNotifier(0); + + void _updateExternalData() { + setState(() { + counter++; + message = "Updated $counter times"; + selectedColor = Colors.primaries[counter % Colors.primaries.length]; + }); + + // Approach 2: Update the widget when external data changes + // Use ValueKey to ensure Flutter recognizes this as a different widget + controller.updateChildWidget( + 1, // Widget ID (second widget added) + ExternalDataWidget( + key: ValueKey('external_$counter'), // Force rebuild with unique key + counter: counter, + message: message, + color: selectedColor, + ), + ); + } + + void _updateReactiveWidget() { + _counterNotifier.value++; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Dynamic Widget Inputs')), + body: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + color: Colors.grey[100], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Four 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('2. External data + updateChildWidget (center) - manual updates'), + const Text('3. ValueListenableBuilder (right) - reactive updates'), + const SizedBox(height: 16), + Row( + children: [ + ElevatedButton(onPressed: _updateExternalData, child: Text('Update External Data ($counter)')), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _updateReactiveWidget, + child: Text('Update Reactive Widget (${_counterNotifier.value})'), + ), + ], + ), + ], + ), + ), + Expanded( + child: LazyCanvas(controller: controller, canvasBackground: const DotGridBackround()), + ), + ], + ), + ); + } + + @override + void dispose() { + _counterNotifier.dispose(); + super.dispose(); + } +} + +/// Approach 1: StatefulWidget that manages its own state +class SelfManagedCounterWidget extends StatefulWidget { + final String label; + + const SelfManagedCounterWidget({super.key, required this.label}); + + @override + State createState() => _SelfManagedCounterWidgetState(); +} + +class _SelfManagedCounterWidgetState extends State { + int _count = 0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + setState(() { + _count++; + }); + }, + child: Container( + width: 120, + height: 80, + decoration: BoxDecoration( + color: Colors.purple, + border: Border.all(color: Colors.black, width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 10)), + Text( + 'Count: $_count', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + const Text('(Tap me)', style: TextStyle(color: Colors.white70, fontSize: 10)), + ], + ), + ), + ); + } +} + +/// Approach 2: Widget that depends on external data +class ExternalDataWidget extends StatelessWidget { + final int counter; + final String message; + final Color color; + + const ExternalDataWidget({super.key, required this.counter, required this.message, required this.color}); + + @override + Widget build(BuildContext context) { + debugPrint('ExternalDataWidget built with counter: $counter, key: $key'); + + return Container( + width: 120, + height: 80, + decoration: BoxDecoration( + color: color, + border: Border.all(color: Colors.black, width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('External', style: TextStyle(color: Colors.white, fontSize: 10)), + Text( + 'Count: $counter', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + ), + Text( + message, + style: const TextStyle(color: Colors.white, fontSize: 8), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index fadf9bb..7b15969 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,6 @@ import './app.dart'; +import './caching_test.dart'; +import './dynamic_widget_example.dart'; import 'package:flutter/material.dart'; void main() { @@ -6,8 +8,58 @@ void main() { MaterialApp( // showPerformanceOverlay: true, title: 'Infinite Lazy 2D Grid Example', - home: const App(), + home: const ExampleChooser(), debugShowCheckedModeBanner: false, ), ); } + +class ExampleChooser extends StatelessWidget { + const ExampleChooser({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Infinite Lazy Grid Examples')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const App())); + }, + child: const Text('Original Example'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const CachingTestApp())); + }, + child: const Text('Widget Caching Test'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const DynamicWidgetExample())); + }, + child: const Text('Dynamic Widget Inputs'), + ), + const SizedBox(height: 32), + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Examples:\n' + '• Original: Basic usage demo\n' + '• Caching Test: Shows Flutter\'s automatic widget optimization\n' + '• Dynamic Inputs: How to handle changing widget data', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/widgets/build_counter_widget.dart b/example/lib/widgets/build_counter_widget.dart new file mode 100644 index 0000000..9fe38a2 --- /dev/null +++ b/example/lib/widgets/build_counter_widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +/// A widget that displays how many times it has been built. +/// Useful for demonstrating widget caching optimization. +class BuildCounterWidget extends StatefulWidget { + final Color color; + final String label; + + const BuildCounterWidget({super.key, required this.color, required this.label}); + + @override + State createState() => _BuildCounterWidgetState(); +} + +class _BuildCounterWidgetState extends State { + static final Map _buildCounts = {}; + + @override + Widget build(BuildContext context) { + final count = _buildCounts[widget.label] = (_buildCounts[widget.label] ?? 0) + 1; + + debugPrint('Building ${widget.label} - Build count: $count'); + + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: widget.color, + border: Border.all(color: Colors.black, width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.label, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + ), + const SizedBox(height: 4), + Text('Builds: $count', style: const TextStyle(color: Colors.white, fontSize: 10)), + ], + ), + ); + } +} diff --git a/lib/core/controller/controller.dart b/lib/core/controller/controller.dart index e027ffc..f8d5580 100644 --- a/lib/core/controller/controller.dart +++ b/lib/core/controller/controller.dart @@ -77,9 +77,9 @@ class LazyCanvasController with ChangeNotifier { // ==================== Child Management ==================== - /// Add a child at a given position with a builder. Returns the child ID. - int addChild(Offset position, WidgetBuilder builder) { - _children[_nextId] = _ChildInfo(gsPosition: position, builder: builder); + /// Add a child at a given position with a widget. Returns the child ID. + int addChild(Offset position, Widget widget) { + _children[_nextId] = _ChildInfo(gsPosition: position, widget: widget); _spatialHash.add(position.toPoint(), _nextId); // add to spatial hash notifyListeners(); return _nextId++; @@ -117,6 +117,19 @@ class LazyCanvasController with ChangeNotifier { } } + /// Update a child's widget. + void updateChildWidget(int id, Widget newWidget) { + if (_children.containsKey(id)) { + final child = _children[id]!; + // Create a new _ChildInfo with the new widget + _children[id] = _ChildInfo(gsPosition: child.gsPosition, widget: newWidget) + ..lastRenderedSize = child.lastRenderedSize; + notifyListeners(); + } else { + throw _ChildNotFoundException; + } + } + /// Get the position of a child by its ID. Offset getPosition(int id) { if (_children.containsKey(id)) { @@ -183,7 +196,7 @@ class LazyCanvasController with ChangeNotifier { return _renderedChildrenCache = idsToBuild.map((id) { final item = _children[id]!; final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale); - var child = item.builder(context); + var child = item.widget; if (debug) child = _Debug(id: id, gs: item.gsPosition, ss: ssPosition, child: child); return ChildInfo(id: id, gsPosition: item.gsPosition, ssPosition: ssPosition, child: child); }).toList(); @@ -237,7 +250,7 @@ class LazyCanvasController with ChangeNotifier { // else do an offstage render childSize ??= childInfo.lastRenderedSize != null && !forceRedraw ? childInfo.lastRenderedSize - : measureWidgetSize(context, childInfo.builder); + : measureWidgetSize(context, (_) => childInfo.widget); /* margin is symmatric on ltrb so diff --git a/lib/core/controller/types.dart b/lib/core/controller/types.dart index 81c89d4..475e501 100644 --- a/lib/core/controller/types.dart +++ b/lib/core/controller/types.dart @@ -9,9 +9,9 @@ final _ChildNotFoundException = Exception('Child with the given ID does not exis class _ChildInfo { Offset gsPosition; Size? lastRenderedSize; - final WidgetBuilder builder; + final Widget widget; - _ChildInfo({required this.gsPosition, required this.builder}); + _ChildInfo({required this.gsPosition, required this.widget}); } class ChildInfo { diff --git a/test/canvas_view_test.dart b/test/canvas_view_test.dart index 62e32f9..b805e72 100644 --- a/test/canvas_view_test.dart +++ b/test/canvas_view_test.dart @@ -15,7 +15,7 @@ void main() { testWidgets('CanvasView renders only visible children and reduces count on zoom out', (WidgetTester tester) async { final controller = LazyCanvasController(debug: true); for (int i = 0; i < 10000; i++) { - controller.addChild(Offset((i % 80) * 100.0, (i ~/ 80) * 100.0), (_) => TestChild(index: i)); + controller.addChild(Offset((i % 80) * 100.0, (i ~/ 80) * 100.0), TestChild(index: i)); } await tester.pumpWidget( MaterialApp( @@ -45,8 +45,8 @@ void main() { testWidgets('CanvasView scales as expected', (WidgetTester tester) async { final controller = LazyCanvasController(debug: true); - controller.addChild(const Offset(0, 0), (_) => TestChild(index: 0)); - controller.addChild(const Offset(100, 100), (_) => TestChild(index: 1)); + controller.addChild(const Offset(0, 0), TestChild(index: 0)); + controller.addChild(const Offset(100, 100), TestChild(index: 1)); await tester.pumpWidget( MaterialApp( home: Scaffold(