diff --git a/example/lib/main.dart b/example/lib/main.dart index bd3b17b..d2c4872 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,7 @@ import 'simple_example.dart'; import './caching_test.dart'; import './dynamic_widget_example.dart'; +import './render_callbacks_example.dart'; import 'package:flutter/material.dart'; void main() { @@ -45,6 +46,13 @@ class ExampleChooser extends StatelessWidget { }, child: const Text('Dynamic Widget Inputs'), ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const RenderCallbacksExample())); + }, + child: const Text('Render Callbacks Demo'), + ), const SizedBox(height: 32), const Padding( padding: EdgeInsets.all(16.0), @@ -52,7 +60,8 @@ class ExampleChooser extends StatelessWidget { 'Examples:\n' '• Simple: Basic usage demo\n' '• Caching Test: To sanity check build counts\n' - '• Dynamic Inputs: How to handle changing widget data', + '• Dynamic Inputs: How to handle changing widget data\n' + '• Render Callbacks: See widgets enter/exit render area', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: Colors.grey), ), diff --git a/example/lib/render_callbacks_example.dart b/example/lib/render_callbacks_example.dart new file mode 100644 index 0000000..5e174b3 --- /dev/null +++ b/example/lib/render_callbacks_example.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_lazy_grid/infinite_lazy_grid.dart'; +import 'widgets/fps.dart'; + +class RenderCallbacksExample extends StatefulWidget { + const RenderCallbacksExample({super.key}); + + @override + State createState() => _RenderCallbacksExampleState(); +} + +class _RenderCallbacksExampleState extends State { + late final LazyCanvasController controller; + final List childIds = []; + final List logs = []; + final ScrollController logScrollController = ScrollController(); + + void onWidgetEnteredRender(CanvasChildId id) { + _addLog('Widget $id entered render area', Colors.green); + } + + void onWidgetExitedRender(CanvasChildId id) { + _addLog('Widget $id exited render area', Colors.red); + } + + @override + void initState() { + super.initState(); + + // Initialize controller with render callbacks + controller = LazyCanvasController( + debug: true, + onWidgetEnteredRender: onWidgetEnteredRender, + onWidgetExitedRender: onWidgetExitedRender, + ); + + _createGrid(); + } + + void _createGrid() { + // Create a 10x10 grid of widgets + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + final id = controller.addChild( + Offset(col * 180.0, row * 180.0), + GridItem(row: row, col: col, color: Colors.primaries[(row * 10 + col) % Colors.primaries.length]), + ); + childIds.add(id); + } + } + } + + void _addLog(String message, Color color) { + setState(() { + logs.insert(0, message); + // Keep only the last 50 logs + if (logs.length > 50) { + logs.removeRange(50, logs.length); + } + }); + + // Auto-scroll to top of logs + WidgetsBinding.instance.addPostFrameCallback((_) { + if (logScrollController.hasClients) { + logScrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } + }); + } + + void _clearLogs() { + setState(() { + logs.clear(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Render Callbacks Example')), + body: Stack( + children: [ + LazyCanvas(controller: controller), + + // FPS counter + const Positioned(bottom: 16, left: 16, child: Fps()), + + // Logs panel + Positioned( + top: 16, + right: 16, + child: Container( + width: 350, + height: 400, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.8), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.only(topLeft: Radius.circular(8), topRight: Radius.circular(8)), + ), + child: Row( + children: [ + const Expanded( + child: Text( + 'Render Callbacks Log', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + IconButton( + onPressed: _clearLogs, + icon: const Icon(Icons.clear, color: Colors.white), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + + // Logs list + Expanded( + child: logs.isEmpty + ? const Center( + child: Text( + 'Pan around to see render callbacks', + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + controller: logScrollController, + padding: const EdgeInsets.all(8), + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + final isEntered = log.contains('entered'); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Icon( + isEntered ? Icons.add_circle : Icons.remove_circle, + color: isEntered ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + log, + style: TextStyle( + color: isEntered ? Colors.green : Colors.red, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + @override + void dispose() { + logScrollController.dispose(); + super.dispose(); + } +} + +class GridItem extends StatelessWidget { + final int row; + final int col; + final Color color; + + const GridItem({super.key, required this.row, required this.col, required this.color}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Tapped grid item ($row, $col)'), duration: const Duration(seconds: 1))); + }, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: color, + border: Border.all(color: Colors.black, width: 1), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$row', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16), + ), + Text( + '$col', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/core/controller/controller.dart b/lib/core/controller/controller.dart index ce30262..7438b79 100644 --- a/lib/core/controller/controller.dart +++ b/lib/core/controller/controller.dart @@ -37,6 +37,9 @@ class LazyCanvasController with ChangeNotifier { double? _lastProcessedScale; bool _markDirty = false; // do any of the non scale or offset changes require a rebuild? final bool useIdsFromArgs; + OnWidgetEnteredRender? onWidgetEnteredRender; + OnWidgetExitedRender? onWidgetExitedRender; + Set _renderedWidgets = {}; // to track which widgets are currently rendered bool debug; final Duration defaultAnimationDuration; @@ -48,6 +51,8 @@ class LazyCanvasController with ChangeNotifier { this.defaultAnimationDuration = const Duration(milliseconds: 300), this.background = const DotGridBackground(), this.useIdsFromArgs = false, + this.onWidgetEnteredRender, + this.onWidgetExitedRender, }) : _hashCellSize = hashCellSize, _buildCacheExtent = buildCacheExtent != null ? buildCacheExtent + Offset(50, 50) : null // only top left is considered so if a widget has long width, it'll not be rendered @@ -117,7 +122,7 @@ class LazyCanvasController with ChangeNotifier { Size? childSize, CanvasChildId? id, }) { - assert(!useIdsFromArgs && useIdsFromArgs && id == null); + assert(!useIdsFromArgs || useIdsFromArgs && id != null); id ??= _uuid.v4(); if (focusOnInit) { assert( @@ -174,17 +179,6 @@ class LazyCanvasController with ChangeNotifier { } } - /// Get the position of a child by its ID. - Offset getPosition(CanvasChildId id) { - if (_children.containsKey(id)) { - return _children[id]!.gsPosition; - } else { - throw _ChildNotFoundException; - } - } - - // ==================== Gesture Handling ==================== - /// Called when a scale gesture starts. void onScaleStart(ScaleStartDetails details) { _baseScale = _scale; @@ -245,6 +239,24 @@ class LazyCanvasController with ChangeNotifier { final idsToBuild = _childrenWithinBuildArea(_gsCenter, _buildExtent); + // do the needed callbacks + if (onWidgetEnteredRender != null || onWidgetExitedRender != null) { + final newRenderedWidgets = idsToBuild.toSet(); + final exitedWidgets = _renderedWidgets.difference(newRenderedWidgets); + final enteredWidgets = newRenderedWidgets.difference(_renderedWidgets); + + Future.microtask(() { + // so that the build is not blocked + for (final id in exitedWidgets) { + onWidgetExitedRender?.call(id); + } + for (final id in enteredWidgets) { + onWidgetEnteredRender?.call(id); + } + }); + } + _renderedWidgets = idsToBuild.toSet(); + return _lastRenderedWidgets = idsToBuild.map((id) { final item = _children[id]!; final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale); diff --git a/lib/core/controller/debug.dart b/lib/core/controller/debug.dart index 76c0c14..6108aaf 100644 --- a/lib/core/controller/debug.dart +++ b/lib/core/controller/debug.dart @@ -17,7 +17,7 @@ class _Debug extends StatelessWidget { left: 0, bottom: -60, child: Text( - 'ID: $id\nGS:(${gs.dx.toInt()},${gs.dy.toInt()})\nSS:(${ss.dx.toInt()},${ss.dy.toInt()})', + 'ID: ${id.substring(0, 4)}\nGS:(${gs.dx.toInt()},${gs.dy.toInt()})\nSS:(${ss.dx.toInt()},${ss.dy.toInt()})', style: monospaceStyle, ), ), diff --git a/lib/core/controller/types.dart b/lib/core/controller/types.dart index eb8cef6..1c38418 100644 --- a/lib/core/controller/types.dart +++ b/lib/core/controller/types.dart @@ -25,3 +25,7 @@ class ChildInfo { enum ScalingMode { resetScale, keepScale, fitInViewport } typedef CanvasChildId = String; + +// listener callbacks +typedef OnWidgetEnteredRender = void Function(CanvasChildId id); +typedef OnWidgetExitedRender = void Function(CanvasChildId id);