add scaling with hit test

This commit is contained in:
2025-07-05 16:41:43 +00:00
parent 3d41b56889
commit 7cfb9457a2
6 changed files with 177 additions and 75 deletions
+19 -1
View File
@@ -10,7 +10,7 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> {
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<App> {
@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)),
);
}
+8 -1
View File
@@ -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,
),
);
}
+3 -3
View File
@@ -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;
+53 -15
View File
@@ -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<int, _CanvasItem> _children = {}; // int for IDs
double _baseScale, _scale;
late Size _canvasSize;
final Map<int, _ChildInfo> _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<ChildInfo> 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();
}
}
+83 -51
View File
@@ -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<Offset> 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<RenderBox> {}
class _CanvasWidgetParentData extends ContainerBoxParentData<RenderBox> {
// 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<RenderBox, _WidgetParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _WidgetParentData> {
ContainerRenderObjectMixin<RenderBox, _CanvasWidgetParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _CanvasWidgetParentData> {
CanvasBackground _canvasBackground;
List<Offset> _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<Offset> 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;
}
}
+11 -4
View File
@@ -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;
}