add feature to allow listeners on build extent enter/exit

This commit is contained in:
2025-07-27 23:43:52 +00:00
parent c67c15487b
commit 86b6f4014d
5 changed files with 265 additions and 14 deletions
+10 -1
View File
@@ -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),
),
+226
View File
@@ -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<RenderCallbacksExample> createState() => _RenderCallbacksExampleState();
}
class _RenderCallbacksExampleState extends State<RenderCallbacksExample> {
late final LazyCanvasController controller;
final List<CanvasChildId> childIds = [];
final List<String> 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),
),
],
),
),
),
);
}
}
+24 -12
View File
@@ -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<CanvasChildId> _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);
+1 -1
View File
@@ -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,
),
),
+4
View File
@@ -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);