diff --git a/example/lib/app.dart b/example/lib/app.dart index 0b56481..4db483b 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -9,8 +9,60 @@ class App extends StatefulWidget { } class _AppState extends State { + Offset _dragStartFocalPoint = Offset.zero; + Offset _lastFocalPoint = Offset.zero; + Offset _offset = Offset.zero; + double _scale = 1.0; + @override Widget build(BuildContext context) { - return Scaffold(body: InfiniteLazy2dGrid()); + return Scaffold( + body: CanvasView( + children: [ + Container(width: 100, height: 100, color: Colors.red), + Container(width: 100, height: 100, color: Colors.green), + Container(width: 100, height: 100, color: Colors.blue), + ], + positions: [ + Offset(0, 0), // Position for red container + Offset(200, 0), // Position for green container + Offset(400, 0), // Position for blue container + ], + offset: _offset, + scale: _scale, + handleScaleUpdate: (details) { + // double dampeningFactor = 0.1; + // double scaleDelta = (details.scale - 1) * dampeningFactor; + // double newScale = _scale * (1 + scaleDelta); + // newScale = newScale.clamp(0.4, 4.0); + + // focal point in the coordinate system of the grid + Offset focalPoint = details.localFocalPoint; + Offset gridFocalPoint = (_offset + focalPoint) / _scale; + + // new scale set + // _scale = newScale; + + // adjust offset to keep the focal point stationary + _offset = gridFocalPoint * _scale - focalPoint; + + // panning + if (_lastFocalPoint != null) { + // right is positive, down is positive + Offset delta = -(details.focalPoint - _lastFocalPoint!); //inverse panning (natural) + const sensitivity = 1.0; // Adjust sensitivity as needed + _offset += delta * sensitivity; + } + _lastFocalPoint = details.focalPoint; + setState(() {}); + }, + handleScaleStart: (details) { + _lastFocalPoint = details.localFocalPoint; + _dragStartFocalPoint = details.localFocalPoint; + setState(() {}); + }, + canvasBackground: const SingleColorBackround(Colors.white), + ), + ); } } diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 092d222..0000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:example/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/lib/core/background.dart b/lib/core/background.dart new file mode 100644 index 0000000..d293a60 --- /dev/null +++ b/lib/core/background.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +abstract class CanvasBackground { + /// the fill color of background + final Color bgColor; + const CanvasBackground({this.bgColor = Colors.white}); + + /// draw the backround on this context. Implement this to have + /// different kinds of backgrounds + void paint(Canvas canvas, Offset offset, double scale, Size canvasSize); +} + +class NoBackground extends CanvasBackground { + const NoBackground(); + + @override + void paint(Canvas canvas, Offset offset, double scale, Size canvasSize) {} +} + +class SingleColorBackround extends CanvasBackground { + const SingleColorBackround(Color backgroundColor) : super(bgColor: backgroundColor); + + @override + void paint(Canvas canvas, Offset offset, double scale, Size canvasSize) { + final paint = Paint() + ..color = bgColor + ..style = PaintingStyle.fill; + canvas.drawRect(Rect.fromLTWH(0, 0, canvasSize.width, canvasSize.height), paint); + } +} diff --git a/lib/core/render.dart b/lib/core/render.dart new file mode 100644 index 0000000..a9c3872 --- /dev/null +++ b/lib/core/render.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'background.dart'; + +/// An infinite canvas that places all the children at the specified positions. +class CanvasView extends StatelessWidget { + final Offset offset; + final List positions; + final List children; + final CanvasBackground canvasBackground; + final double scale; + final void Function(ScaleUpdateDetails details) handleScaleUpdate; + final void Function(ScaleStartDetails details) handleScaleStart; + + const CanvasView({ + required this.children, + required this.positions, + required this.offset, + required this.scale, + required this.handleScaleUpdate, + required this.handleScaleStart, + required this.canvasBackground, + super.key, + }) : assert(children.length == positions.length, 'Children and positions must have the same length'); + + @override + Widget build(BuildContext context) { + return Container( + color: canvasBackground.bgColor, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onScaleUpdate: handleScaleUpdate, + onScaleStart: handleScaleStart, + child: _CanvasRenderObject( + offset: offset, + scale: scale, + positions: positions, + canvasBackground: canvasBackground, + children: children, + ), + ), + ); + } +} + +class _CanvasRenderObject extends MultiChildRenderObjectWidget { + final Offset offset; + final double scale; + + final List positions; + final CanvasBackground canvasBackground; + + const _CanvasRenderObject({ + required this.offset, + required this.scale, + required this.positions, + required super.children, + required this.canvasBackground, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return _CanvasRenderBox(offset: offset, scale: scale, positions: positions, canvasBackground: canvasBackground); + } + + @override + void updateRenderObject(BuildContext context, _CanvasRenderBox renderObject) { + renderObject + ..offset = offset + ..positions = positions + ..scale = scale; + } +} + +class _CanvasParentData extends ContainerBoxParentData {} + +class _CanvasRenderBox extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + CanvasBackground canvasBackground; + Offset _offset; + double _scale; + List _positions; + + _CanvasRenderBox({required positions, required offset, required scale, required this.canvasBackground}) + : _offset = offset, + _positions = positions, + _scale = scale; + + set offset(Offset value) { + if (_offset == value) return; + _offset = value; + markNeedsPaint(); + } + + set positions(List value) { + if (_positions == value) return; + _positions = value; + markNeedsLayout(); + } + + set scale(double value) { + if (_scale == value) return; + _scale = value; + markNeedsPaint(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _CanvasParentData) { + child.parentData = _CanvasParentData(); + } + } + + @override + void performLayout() { + size = constraints.biggest; + + RenderBox? child = firstChild; + while (child != null) { + final _CanvasParentData childParentData = child.parentData! as _CanvasParentData; + child.layout(constraints.loosen(), parentUsesSize: false); // TODO: parentUsesSize should be false, but idk + child = childParentData.nextSibling; + } + } + + @override + void paint(PaintingContext context, Offset canvasStartOffset) { + final canvas = context.canvas; + + canvasBackground.paint(canvas, _offset, _scale, size); + + RenderBox? child = firstChild; + for (int idx = 0; child != null; idx++) { + final _CanvasParentData childParentData = child.parentData! as _CanvasParentData; + final paintAt = _positions[idx] * _scale - _offset; + context.paintChild(child, paintAt); + child = childParentData.nextSibling; + } + } + + // do i need to override hitTest? yes I do + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + // args are propagating so this may happen, just ignore it + //is it a double render bug? + if (childCount != _positions.length) { + return false; + } + // TODO: this is bit slow, so the tap is shown slightly after the actual tap + bool hitChild = false; + RenderBox? child = lastChild; + for (int i = childCount - 1; i >= 0; i--) { + if (child != null) { + final _CanvasParentData childParentData = child.parentData! as _CanvasParentData; + // child is at _positions[i] in grid space + final ssChildPosition = _positions[i] * _scale - _offset; // child position in screen space + final positionInChildSpace = position - ssChildPosition; // position in child space + if ((child.size * _scale).contains(positionInChildSpace)) { + final isHit = result.addWithPaintOffset( + offset: null, // i convert it directly to child space + position: positionInChildSpace, + hitTest: (BoxHitTestResult result, Offset transformed) { + return child!.hitTest(result, position: transformed); + }, + ); + if (isHit) { + hitChild = true; + break; + } + } + child = childParentData.previousSibling; + } + } + + return hitChild; + } +} diff --git a/lib/infinite_lazy_2d_grid.dart b/lib/infinite_lazy_2d_grid.dart index 82d781f..0cf8f07 100644 --- a/lib/infinite_lazy_2d_grid.dart +++ b/lib/infinite_lazy_2d_grid.dart @@ -1,15 +1,2 @@ -import 'package:flutter/widgets.dart'; - -class InfiniteLazy2dGrid extends StatefulWidget { - const InfiniteLazy2dGrid({super.key}); - - @override - State createState() => _InfiniteLazy2dGridState(); -} - -class _InfiniteLazy2dGridState extends State { - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} +export "./core/background.dart"; +export "./core/render.dart"; diff --git a/lib/utils/offset_extensions.dart b/lib/utils/offset_extensions.dart new file mode 100644 index 0000000..b0b1e69 --- /dev/null +++ b/lib/utils/offset_extensions.dart @@ -0,0 +1,9 @@ +import 'dart:ui' show Offset; + +extension OffsetCartesionOps on Offset { + double distanceSquared(Offset other) { + final dx = this.dx - other.dx; + final dy = this.dy - other.dy; + return dx * dx + dy * dy; + } +}