diff --git a/example/lib/app.dart b/example/lib/app.dart index c7e7791..c5457af 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -10,7 +10,7 @@ class App extends StatefulWidget { } class _AppState extends State { - final CanvasController controller = CanvasController(debug: true); + final CanvasController controller = CanvasController(debug: true, initialScale: 1); @override void initState() { @@ -24,6 +24,24 @@ class _AppState extends State { @override Widget build(BuildContext context) { return Scaffold( + floatingActionButton: Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + FloatingActionButton( + onPressed: () { + controller.updateScalebyDelta(0.1); + }, + child: const Icon(Icons.zoom_in), + ), + FloatingActionButton( + onPressed: () { + controller.updateScalebyDelta(-0.1); + }, + child: const Icon(Icons.zoom_out), + ), + ], + ), body: CanvasView(controller: controller, canvasBackground: SingleColorBackround(Colors.white)), ); } diff --git a/example/lib/main.dart b/example/lib/main.dart index 2321845..b184e69 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,5 +2,12 @@ import 'package:example/app.dart'; import 'package:flutter/material.dart'; void main() { - runApp(MaterialApp(title: 'Infinite Lazy 2D Grid Example', home: const App(), debugShowCheckedModeBanner: false)); + runApp( + MaterialApp( + // showPerformanceOverlay: true, + title: 'Infinite Lazy 2D Grid Example', + home: const App(), + debugShowCheckedModeBanner: false, + ), + ); } diff --git a/lib/core/background.dart b/lib/core/background.dart index c57ce8f..5f59952 100644 --- a/lib/core/background.dart +++ b/lib/core/background.dart @@ -7,21 +7,21 @@ abstract class CanvasBackground { /// draw the backround on this context. Implement this to have /// different kinds of backgrounds - void paint(Canvas canvas, Size canvasSize); + void paint(Canvas canvas, Size canvasSize, double scale); } class NoBackground extends CanvasBackground { const NoBackground(); @override - void paint(Canvas canvas, Size canvasSize) {} + void paint(Canvas canvas, Size canvasSize, double scale) {} } class SingleColorBackround extends CanvasBackground { const SingleColorBackround(Color backgroundColor) : super(bgColor: backgroundColor); @override - void paint(Canvas canvas, Size canvasSize) { + void paint(Canvas canvas, Size canvasSize, double scale) { final paint = Paint() ..color = bgColor ..style = PaintingStyle.fill; diff --git a/lib/core/controller.dart b/lib/core/controller.dart index 10166f9..bdddf2a 100644 --- a/lib/core/controller.dart +++ b/lib/core/controller.dart @@ -12,11 +12,18 @@ typedef WidgetBuilder = Widget Function(); // ignore: non_constant_identifier_names final _ChildNotFoundException = Exception('Child with the given ID does not exist'); -class _CanvasItem { +class _ChildInfo { Offset gsPosition; final WidgetBuilder builder; - _CanvasItem({required this.gsPosition, required this.builder}); + _ChildInfo({required this.gsPosition, required this.builder}); +} + +class ChildInfo { + Offset gsPosition; + Offset ssPosition; + Widget child; + ChildInfo({required this.gsPosition, required this.ssPosition, required this.child}); } // ------------------------------ Controller ------------------------------ @@ -25,20 +32,30 @@ class _CanvasItem { class CanvasController with ChangeNotifier { int _nextId = 0; // surely we won't run out of IDs, Clueless Offset _gsTopLeftOffset = Offset.zero; - final Map _children = {}; // int for IDs + double _baseScale, _scale; + late Size _canvasSize; + final Map _children = {}; // int for IDs bool debug; - CanvasController({this.debug = false}); + CanvasController({this.debug = false, double initialScale = 1}) + : _scale = initialScale, + _baseScale = initialScale, + assert(initialScale > 0, 'Initial scale must be greater than 0'); // -------------------- getters -------------------- Offset get offset => _gsTopLeftOffset; - + double get scale => _scale; + Size get canvasSize => _canvasSize; // -------------------- public functions -------------------- + void onCanvasSizeChange(Size size) { + _canvasSize = size; + } + // -------- child manip -------- int addChild(Offset position, WidgetBuilder builder) { - _children[_nextId] = _CanvasItem(gsPosition: position, builder: builder); + _children[_nextId] = _ChildInfo(gsPosition: position, builder: builder); return _nextId++; } @@ -63,26 +80,47 @@ class CanvasController with ChangeNotifier { } } + void onScaleStart(ScaleStartDetails details) { + _baseScale = _scale; + } + // -------- gesture handling -------- - void handleGesture(ScaleUpdateDetails details) { - // handle gestures like pinch to zoom, pan, etc. - + void onScaleUpdate(ScaleUpdateDetails details) { + // uses usual display conventions and final vector postion - initial vector position // convention is that if I drag from right to left, dx is negative - // for top to bottom, dy is postive ( so usual display conventions and final vector postion - initial vector position ) - final delta = details.focalPointDelta; + // for top to bottom, dy is postive - _gsTopLeftOffset -= delta; + // scale + offset is => scale then offset + + if (details.scale != 1) { + final newScale = _baseScale * details.scale; + _gsTopLeftOffset = newGsTopLeftOnScaling(_gsTopLeftOffset, details.localFocalPoint, _scale, newScale); + _scale = newScale; + } + + if (details.focalPointDelta != Offset.zero) { + // if ss distnace is x, and zoom is 2x, gs only moves by x/2 + _gsTopLeftOffset -= details.focalPointDelta / _scale; + } notifyListeners(); } - List<(Offset, Widget)> widgetsWithScreenPositions() { + void updateScalebyDelta(double delta) { + final focalPoint = Offset(canvasSize.width / 2, canvasSize.height / 2); + final newScale = _scale + delta; + _gsTopLeftOffset = newGsTopLeftOnScaling(_gsTopLeftOffset, focalPoint, _scale, newScale); + _scale = newScale; + notifyListeners(); + } + + List widgetsWithScreenPositions() { return _children.entries.map((entry) { final item = entry.value; - final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset); + final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale); var child = item.builder(); if (debug) child = _Debug(id: entry.key, gs: item.gsPosition, ss: ssPosition, child: child); - return (ssPosition, child); + return ChildInfo(gsPosition: item.gsPosition, ssPosition: ssPosition, child: child); }).toList(); } } diff --git a/lib/core/render.dart b/lib/core/render.dart index f5111ec..5c06d38 100644 --- a/lib/core/render.dart +++ b/lib/core/render.dart @@ -6,11 +6,6 @@ import 'package:infinite_lazy_2d_grid/utils/styles.dart'; import 'background.dart'; -/** - * Working of some of the flutter code used here: - - */ - /// An infinite canvas that places all the children at the specified positions. class CanvasView extends StatelessWidget { final CanvasBackground canvasBackground; @@ -23,16 +18,20 @@ class CanvasView extends StatelessWidget { return Container( color: canvasBackground.bgColor, child: GestureDetector( - onScaleUpdate: controller.handleGesture, + behavior: HitTestBehavior.translucent, + onScaleUpdate: controller.onScaleUpdate, + onScaleStart: controller.onScaleStart, child: ListenableBuilder( listenable: controller, builder: (_, _) { final childrenWithPositions = controller.widgetsWithScreenPositions(); - final ssPositions = childrenWithPositions.map((e) => e.$1).toList(); - final children = childrenWithPositions.map((e) => e.$2).toList(); + final ssPositions = childrenWithPositions.map((e) => e.ssPosition).toList(); + final children = childrenWithPositions.map((e) => e.child).toList(); final canvas = _CanvasRenderObject( canvasBackground: canvasBackground, ssPositions: ssPositions, + scale: controller.scale, + onCanvasSizeChange: controller.onCanvasSizeChange, children: children, ); @@ -43,7 +42,10 @@ class CanvasView extends StatelessWidget { Positioned( top: 16, left: 16, - child: Text('Offset: ${controller.offset.coord()}', style: monospaceStyle), + child: Text( + 'Offset: ${controller.offset.coord()}\nScale: ${controller.scale.toStringAsFixed(1)}', + style: monospaceStyle, + ), ), ], ); @@ -58,54 +60,79 @@ 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 ssPositions; + final double scale; final CanvasBackground canvasBackground; + final Function onCanvasSizeChange; const _CanvasRenderObject({ required this.ssPositions, - required super.children, // children go to the MultiChildRenderObjectWidget + required this.scale, required this.canvasBackground, - }) : assert(ssPositions.length == children.length, 'Children and positions must have the same length'); + required this.onCanvasSizeChange, + required super.children, // children go to the MultiChildRenderObjectWidget + }) : assert(ssPositions.length == children.length, 'Children and positions must have the same length'), + assert(scale != 0); @override RenderObject createRenderObject(BuildContext context) { - return _CanvasRenderBox(ssPositions: ssPositions, canvasBackground: canvasBackground); + return _CanvasRenderBox( + ssPositions: ssPositions, + scale: scale, + canvasBackground: canvasBackground, + onCanvasSizeChange: onCanvasSizeChange, + ); } @override void updateRenderObject(BuildContext context, _CanvasRenderBox renderObject) { renderObject ..ssPositions = ssPositions - ..canvasBackground = canvasBackground; + ..canvasBackground = canvasBackground + ..scale = scale; } } -/// Dummy empty parent data for each child -class _WidgetParentData extends ContainerBoxParentData {} +class _CanvasWidgetParentData extends ContainerBoxParentData { + // there is already an "offset" defined in BoxParentdata that is exactly what I want + late double scale; // scale is same for all rn but this makes it trivial to expand to children with diff scales +} class _CanvasRenderBox extends RenderBox with - ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin { + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { CanvasBackground _canvasBackground; List _ssPositions; + double _scale; + Function onCanvasSizeChange; - _CanvasRenderBox({required ssPositions, required canvasBackground}) + _CanvasRenderBox({required ssPositions, required scale, required canvasBackground, required this.onCanvasSizeChange}) : _ssPositions = ssPositions, + _scale = scale, _canvasBackground = canvasBackground; @override void setupParentData(RenderBox child) { - if (child.parentData is! _WidgetParentData) { - child.parentData = _WidgetParentData(); + if (child.parentData is! _CanvasWidgetParentData) { + child.parentData = _CanvasWidgetParentData(); } } set ssPositions(List ssPositions) { + assert(ssPositions.length == childCount); if (_ssPositions != ssPositions) { _ssPositions = ssPositions; - markNeedsPaint(); + markNeedsLayout(); + } + } + + set scale(double scale) { + if (_scale != scale) { + _scale = scale; + markNeedsLayout(); } } @@ -118,59 +145,64 @@ class _CanvasRenderBox extends RenderBox @override void performLayout() { + assert(childCount == _ssPositions.length); size = constraints.biggest; // expand as much as possible for the parent + onCanvasSizeChange(size); // notify the controller about the size RenderBox? child = firstChild; + int index = 0; while (child != null) { - final _WidgetParentData parentData = child.parentData! as _WidgetParentData; + final _CanvasWidgetParentData childParentData = child.parentData! as _CanvasWidgetParentData; + childParentData.offset = _ssPositions[index++]; + childParentData.scale = _scale; // 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 = parentData.nextSibling; + child = childParentData.nextSibling; } } @override void paint(PaintingContext context, Offset canvasStartOffset) { - final canvas = context.canvas; - + assert(childCount == _ssPositions.length); // use the canvas background painter, pass it the canvas and that should handle drawing the background - _canvasBackground.paint(canvas, size); + _canvasBackground.paint(context.canvas, size, _scale); + // 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 + // change the offset as well so should work for me ) and this is the flutter way of implementation + // on most other stuff ( single source of truth for paint & hit test etc ) RenderBox? child = firstChild; - for (int idx = 0; child != null; idx++) { - final _WidgetParentData parentData = child.parentData! as _WidgetParentData; - context.paintChild(child, _ssPositions[idx]); - child = parentData.nextSibling; + while (child != null) { + final _CanvasWidgetParentData childParentData = child.parentData! as _CanvasWidgetParentData; + + context.canvas.save(); + // relying purely on canvas manips here + 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.canvas.restore(); + child = childParentData.nextSibling; } } @override - bool hitTest(BoxHitTestResult result, {required Offset position}) { - final ssHitPosition = position; - - if (!size.contains(ssHitPosition)) { - return false; - } - - // Check children in reverse order (last painted = first hit) + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { RenderBox? child = lastChild; - for (int idx = _ssPositions.length - 1; child != null; idx--) { - final _WidgetParentData parentData = child.parentData! as _WidgetParentData; - - // Transform the hit position relative to the child's position - final childPosition = ssHitPosition - _ssPositions[idx]; - - if (child.hitTest(BoxHitTestResult.wrap(result), position: childPosition)) { + while (child != null) { + // need to get a coordinate in child space + final childParentData = child.parentData as _CanvasWidgetParentData; + final Offset childSpacePosition = + (position - childParentData.offset /* <- distance in screen space */ ) / childParentData.scale; + // divide by scale so if screen space is 2x, it's only x in child space + if (child.hitTest(result, position: childSpacePosition)) { return true; } - - child = parentData.previousSibling; + child = childParentData.previousSibling; } - - // If no child was hit, the canvas itself handles the hit - result.add(BoxHitTestEntry(this, ssHitPosition)); - return true; + return false; } } diff --git a/lib/utils/conversions.dart b/lib/utils/conversions.dart index 840015e..5181d74 100644 --- a/lib/utils/conversions.dart +++ b/lib/utils/conversions.dart @@ -1,11 +1,18 @@ import 'dart:ui'; /// Convert grid space coordinates to screen space coordinates -Offset gsToSs(Offset gsPosition, Offset gsTopLeft) { - return gsPosition - gsTopLeft; +Offset gsToSs(Offset gsPosition, Offset gsTopLeft, double scale) { + return (gsPosition - gsTopLeft) * scale; } /// Convert screen space coordinates to grid space coordinates -Offset ssToGs(Offset ssPosition, Offset gsTopLeft) { - return ssPosition + gsTopLeft; +Offset ssToGs(Offset ssPosition, Offset gsTopLeft, double scale) { + return ssPosition / scale + gsTopLeft; +} + +Offset newGsTopLeftOnScaling(Offset gsTopLeft, Offset ssFocalPoint, double oldScale, double newScale) { + // gsFocal remains same + // we change gsTopLeft to keep ssFocalPoint same as well + Offset gsFocalPoint = ssToGs(ssFocalPoint, gsTopLeft, oldScale); + return gsFocalPoint - ssFocalPoint / newScale; }