diff --git a/example/lib/app.dart b/example/lib/app.dart index 8bad91d..1d338a6 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -11,15 +11,21 @@ class App extends StatefulWidget { } class _AppState extends State { - final CanvasController controller = CanvasController(debug: true, initialScale: 1); + final CanvasController controller = CanvasController(debug: true); @override void initState() { super.initState(); - for (int i = 0; i < 10000; i++) { + for (int i = 0; i < 10; i++) { controller.addChild( Offset((i % 80) * 100.0, (i ~/ 80) * 100.0), - () => InfoContainer(color: Colors.primaries[i % Colors.primaries.length]), + (_) => GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Tapped a container'))); + controller.focusOnChild(context, i); + }, + child: InfoContainer(color: Colors.primaries[i % Colors.primaries.length]), + ), ); } } @@ -61,11 +67,6 @@ class InfoContainer extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Tapped a container'))); - }, - child: Container(color: color, width: 50, height: 50), - ); + return Container(color: color, width: 50, height: 50); } } diff --git a/lib/core/controller/controller.dart b/lib/core/controller/controller.dart index a286150..444462f 100644 --- a/lib/core/controller/controller.dart +++ b/lib/core/controller/controller.dart @@ -1,6 +1,10 @@ +import 'dart:collection'; + import 'package:flutter/material.dart'; -import 'package:infinite_lazy_2d_grid/core/spatial_hashing.dart'; -import 'package:infinite_lazy_2d_grid/utils/offset_extensions.dart'; +import 'package:infinite_lazy_2d_grid/utils/measure_size.dart'; +import '../spatial_hashing.dart'; +import '../../utils/offset_extensions.dart'; +import '../../utils/size_extensions.dart'; import '../../utils/conversions.dart'; import '../../utils/styles.dart'; @@ -11,29 +15,26 @@ part 'types.dart'; class CanvasController with ChangeNotifier { int _nextId = 0; // surely we won't run out of IDs, Clueless Offset _gsTopLeftOffset = Offset.zero; - double _baseScale, _scale; + Offset? _lastProcessedOffset; + double _baseScale = 1, _scale = 1; + double? _lastProcessedScale; late Size _canvasSize; - final Map _children = {}; // int for IDs + final HashMap _children = HashMap(); // int for IDs final Offset? _buildCacheExtent; late Offset _buildExtent; final Size _hashCellSize; bool _init = false; late final SpatialHashing _spatialHash; + List _renderedChildren = []; bool debug; - CanvasController({ - double initialScale = 1, - this.debug = false, - Offset? buildCacheExtent, - Size hashCellSize = const Size(100, 100), - }) : _scale = initialScale, - _baseScale = initialScale, - _hashCellSize = hashCellSize, - _buildCacheExtent = buildCacheExtent != null ? buildCacheExtent + Offset(50, 50) : null, - // only top left is considered to if a widget has long width, it'll not be rendered - // unless the cache extent is sufficient - assert(initialScale > 0, 'Initial scale must be greater than 0') { + CanvasController({this.debug = false, Offset? buildCacheExtent, Size hashCellSize = const Size(100, 100)}) + : _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 + // unless the cache extent is sufficient + { _spatialHash = SpatialHashing(cellSize: _hashCellSize); } @@ -43,6 +44,7 @@ class CanvasController 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; // ==================== Public Functions ==================== @@ -61,6 +63,10 @@ class CanvasController with ChangeNotifier { } } + void onChildSizeChange(int id, Size size) { + _children[id]!.lastRenderedSize = size; + } + // ==================== Child Management ==================== /// Add a child at a given position with a builder. Returns the child ID. @@ -140,17 +146,24 @@ class CanvasController with ChangeNotifier { // ==================== Positioning Logic ==================== /// Currently rendered widgets with their position info - List widgetsWithScreenPositions() { + List widgetsWithScreenPositions(BuildContext context, {bool forceRebuild = false}) { if (!_init) return []; + if (_cleanRenderState && !forceRebuild) { + return _renderedChildren; + } + + _lastProcessedOffset = _gsTopLeftOffset; + _lastProcessedScale = _scale; + final idsToBuild = _childrenWithinBuildArea(_gsCenter, _buildExtent); - return idsToBuild.map((id) { + return _renderedChildren = idsToBuild.map((id) { final item = _children[id]!; final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale); - var child = item.builder(); + var child = item.builder(context); if (debug) child = _Debug(id: id, gs: item.gsPosition, ss: ssPosition, child: child); - return ChildInfo(gsPosition: item.gsPosition, ssPosition: ssPosition, child: child); + return ChildInfo(id: id, gsPosition: item.gsPosition, ssPosition: ssPosition, child: child); }).toList(); } @@ -159,4 +172,68 @@ class CanvasController with ChangeNotifier { final items = _spatialHash.getPointsAround(center.toPoint(), halfExtent); return items.map((item) => item.data).toList(); // data is the child id here } + + // ==================== Centering & Focus Functions ==================== + + /// Center the canvas so that the given screen-space offset is at the center of the viewport. + void centerOnScreenOffset(Offset ssOffset) { + centerOnGridOffset(ssToGs(ssOffset, _gsTopLeftOffset, _scale)); + } + + /// Center the canvas so that the given grid-space offset is at the center of the viewport. + void centerOnGridOffset(Offset gsOffset) { + // if 2x scale you need to adjust lesser + _gsTopLeftOffset = gsOffset + (canvasSize * (2 * scale)).toOffset(); + notifyListeners(); + } + + /// Focus the viewport on a child by its ID, with a margin in screen-space. + /// If it's already rendered, size will be picked up from the child widget. If not + /// an offstage rendering will be used ( double render ) + /// Preferred horizontal margin used for [ScalingMode.fitInViewport]. + void focusOnChild( + BuildContext context, + int id, { + ScalingMode scalingMode = ScalingMode.keepScale, + double preferredHorizontalMargin = 16, + Size? childSize, + forceRedraw = false, + }) { + if (!_children.containsKey(id)) { + throw _ChildNotFoundException; + } + + final childInfo = _children[id]!; + + // try to figure out the size, take from render cache if available + // else do an offstage render + childSize ??= childInfo.lastRenderedSize != null && !forceRedraw + ? childInfo.lastRenderedSize + : measureWidgetSize(context, childInfo.builder); + + /* + margin is symmatric on ltrb so + 2mx + cx = screenWidth + 2my + cy = screenHeight + where c is child size in screen space and m is margin + */ + + switch (scalingMode) { + case ScalingMode.keepScale: + // do nothing + break; + case ScalingMode.resetScale: + _scale = 1; + case ScalingMode.fitInViewport: + // the scale needs to be determined in this case + // and hence a margin is needed to constrain on x, to get the scale, we then center it along y + _scale = (canvasSize.width - 2 * preferredHorizontalMargin) / childSize!.width; + break; + } + + final marginOffset = ((canvasSize.toOffset() - (childSize! * scale).toOffset()) / (2 * scale)).makeAtleast(0); + _gsTopLeftOffset = childInfo.gsPosition - marginOffset; + + notifyListeners(); + } } diff --git a/lib/core/controller/types.dart b/lib/core/controller/types.dart index 2e079c4..81c89d4 100644 --- a/lib/core/controller/types.dart +++ b/lib/core/controller/types.dart @@ -1,7 +1,5 @@ part of 'controller.dart'; -typedef WidgetBuilder = Widget Function(); - // ------------------------------ Private Types ------------------------------ // some simple exceptions @@ -10,14 +8,18 @@ final _ChildNotFoundException = Exception('Child with the given ID does not exis class _ChildInfo { Offset gsPosition; + Size? lastRenderedSize; final WidgetBuilder builder; _ChildInfo({required this.gsPosition, required this.builder}); } class ChildInfo { + int id; Offset gsPosition; Offset ssPosition; Widget child; - ChildInfo({required this.gsPosition, required this.ssPosition, required this.child}); + ChildInfo({required this.id, required this.gsPosition, required this.ssPosition, required this.child}); } + +enum ScalingMode { resetScale, keepScale, fitInViewport } diff --git a/lib/core/render.dart b/lib/core/render.dart index 6194524..dadc7f0 100644 --- a/lib/core/render.dart +++ b/lib/core/render.dart @@ -22,14 +22,17 @@ class CanvasView extends StatelessWidget { child: ListenableBuilder( listenable: controller, builder: (_, _) { - final childrenWithPositions = controller.widgetsWithScreenPositions(); + final childrenWithPositions = controller.widgetsWithScreenPositions(context); final ssPositions = childrenWithPositions.map((e) => e.ssPosition).toList(); + final childrenIds = childrenWithPositions.map((e) => e.id).toList(); final children = childrenWithPositions.map((e) => e.child).toList(); final canvas = _CanvasRenderObject( + childrenIds: childrenIds, canvasBackground: canvasBackground, ssPositions: ssPositions, scale: controller.scale, onCanvasSizeChange: controller.onCanvasSizeChange, + onChildSizeChange: controller.onChildSizeChange, children: children, ); @@ -59,16 +62,20 @@ class CanvasView extends StatelessWidget { /// A combined widget for all the render object of the children + background. /// Everything is in screen space here class _CanvasRenderObject extends MultiChildRenderObjectWidget { + final List childrenIds; final List ssPositions; final double scale; final CanvasBackground canvasBackground; final Function onCanvasSizeChange; + final Function onChildSizeChange; const _CanvasRenderObject({ + required this.childrenIds, required this.ssPositions, required this.scale, required this.canvasBackground, required this.onCanvasSizeChange, + required this.onChildSizeChange, required super.children, // children go to the MultiChildRenderObjectWidget }) : assert(ssPositions.length == children.length, 'Children and positions must have the same length'), assert(scale != 0); @@ -76,16 +83,19 @@ class _CanvasRenderObject extends MultiChildRenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) { return _CanvasRenderBox( + childrenIds: childrenIds, ssPositions: ssPositions, scale: scale, canvasBackground: canvasBackground, onCanvasSizeChange: onCanvasSizeChange, + onChildSizeChange: onChildSizeChange, ); } @override void updateRenderObject(BuildContext context, _CanvasRenderBox renderObject) { renderObject + ..childrenIds = childrenIds ..ssPositions = ssPositions ..canvasBackground = canvasBackground ..scale = scale; @@ -94,6 +104,7 @@ class _CanvasRenderObject extends MultiChildRenderObjectWidget { class _CanvasWidgetParentData extends ContainerBoxParentData { // there is already an "offset" defined in BoxParentdata that is exactly what I want + late int id; late double scale; // scale is same for all rn but this makes it trivial to expand to children with diff scales } @@ -102,14 +113,24 @@ class _CanvasRenderBox extends RenderBox ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { CanvasBackground _canvasBackground; + List _childrenIds; List _ssPositions; double _scale; Function onCanvasSizeChange; + Function onChildSizeChange; - _CanvasRenderBox({required ssPositions, required scale, required canvasBackground, required this.onCanvasSizeChange}) - : _ssPositions = ssPositions, - _scale = scale, - _canvasBackground = canvasBackground; + _CanvasRenderBox({ + required childrenIds, + required ssPositions, + required scale, + required canvasBackground, + required this.onCanvasSizeChange, + required this.onChildSizeChange, + }) : assert(childrenIds.length == ssPositions.length), + _childrenIds = childrenIds, + _ssPositions = ssPositions, + _scale = scale, + _canvasBackground = canvasBackground; @override void setupParentData(RenderBox child) { @@ -128,6 +149,12 @@ class _CanvasRenderBox extends RenderBox } } + set childrenIds(List childrenIds) { + if (_childrenIds != childrenIds) { + _childrenIds = childrenIds; + } + } + set scale(double scale) { if (_scale != scale) { _scale = scale; @@ -153,11 +180,16 @@ class _CanvasRenderBox extends RenderBox int index = 0; while (child != null) { final _CanvasWidgetParentData childParentData = child.parentData! as _CanvasWidgetParentData; - childParentData.offset = _ssPositions[index++]; + childParentData.offset = _ssPositions[index]; + childParentData.id = _childrenIds[index]; childParentData.scale = _scale; + index++; // loosen so like a stack can take it's own size inside parent - // like a stack parent is not using size and is always expanded hence the second arg - child.layout(constraints.loosen(), parentUsesSize: false); + child.layout(constraints.loosen(), parentUsesSize: true); + + // notify the controller about the size of the child + onChildSizeChange(childParentData.id, child.size); + child = childParentData.nextSibling; } } diff --git a/lib/utils/measure_size.dart b/lib/utils/measure_size.dart new file mode 100644 index 0000000..ea334c1 --- /dev/null +++ b/lib/utils/measure_size.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +/// Utility to synchronously measure the size of a widget by building it offscreen in an Overlay. +/// Only works for widgets with deterministic, constraint-based sizing (no async/layout dependencies). +Size measureWidgetSize(BuildContext context, WidgetBuilder builder, {BoxConstraints? constraints}) { + final key = GlobalKey(); + final overlay = Overlay.of(context); + Size? measuredSize; + + final widget = Offstage( + child: ConstrainedBox( + constraints: constraints ?? const BoxConstraints(), + child: Container(key: key, child: builder(context)), + ), + ); + + final entry = OverlayEntry(builder: (_) => widget); + overlay.insert(entry); + + WidgetsBinding.instance.handleDrawFrame(); + + final ctx = key.currentContext; + if (ctx != null) { + measuredSize = (ctx.findRenderObject() as RenderBox).size; + } + entry.remove(); + return measuredSize ?? Size.zero; +} diff --git a/lib/utils/offset_extensions.dart b/lib/utils/offset_extensions.dart index b64887a..d837fa3 100644 --- a/lib/utils/offset_extensions.dart +++ b/lib/utils/offset_extensions.dart @@ -8,10 +8,29 @@ extension OffsetCartesionOps on Offset { return dx * dx + dy * dy; } + Offset operator +(Offset other) { + return Offset(dx + other.dx, dy + other.dy); + } + Offset operator -(Offset other) { return Offset(dx - other.dx, dy - other.dy); } + Offset operator *(double scalar) { + return Offset(dx * scalar, dy * scalar); + } + + Offset makeAtleast(double value) { + return Offset(dx < value ? value : dx, dy < value ? value : dy); + } + + Offset operator /(double scalar) { + if (scalar == 0) { + throw ArgumentError('Division by zero is not allowed.'); + } + return Offset(dx / scalar, dy / scalar); + } + Point toPoint() { return Point(dx, dy); } diff --git a/lib/utils/size_extensions.dart b/lib/utils/size_extensions.dart new file mode 100644 index 0000000..c77059c --- /dev/null +++ b/lib/utils/size_extensions.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +extension SizeOps on Size { + Size operator /(double scalar) { + if (scalar == 0) { + throw ArgumentError('Division by zero is not allowed.'); + } + return Size(width / scalar, height / scalar); + } + + Size operator *(double scalar) { + return Size(width * scalar, height * scalar); + } + + Offset toOffset() { + return Offset(width, height); + } +} diff --git a/test/canvas_view_test.dart b/test/canvas_view_test.dart index 2843579..ce671f9 100644 --- a/test/canvas_view_test.dart +++ b/test/canvas_view_test.dart @@ -15,9 +15,9 @@ class TestChild extends StatelessWidget { void main() { testWidgets('CanvasView renders only visible children and reduces count on zoom out', (WidgetTester tester) async { - final controller = CanvasController(debug: true, initialScale: 1); + final controller = CanvasController(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( @@ -46,9 +46,9 @@ void main() { }); testWidgets('CanvasView scales as expected', (WidgetTester tester) async { - final controller = CanvasController(debug: true, initialScale: 1); - controller.addChild(const Offset(0, 0), () => TestChild(index: 0)); - controller.addChild(const Offset(100, 100), () => TestChild(index: 1)); + final controller = CanvasController(debug: true); + controller.addChild(const Offset(0, 0), (_) => TestChild(index: 0)); + controller.addChild(const Offset(100, 100), (_) => TestChild(index: 1)); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -57,6 +57,7 @@ void main() { ), ); await tester.pumpAndSettle(); + final context = tester.element(find.byType(CanvasView)); // Initial size check final finder = find.byType(TestChild); @@ -67,7 +68,7 @@ void main() { controller.onScaleUpdate(ScaleUpdateDetails(focalPoint: const Offset(0, 0), scale: 2.0)); await tester.pumpAndSettle(); expect(controller.scale, 2.0); - final ssPositions = controller.widgetsWithScreenPositions().map((e) => e.ssPosition).toList(); + final ssPositions = controller.widgetsWithScreenPositions(context).map((e) => e.ssPosition).toList(); expect(ssPositions, [Offset.zero, const Offset(200, 200)]); }); }