add scaling with hit test
This commit is contained in:
+19
-1
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+86
-54
@@ -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)) {
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
RenderBox? child = lastChild;
|
||||
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 = childParentData.previousSibling;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check children in reverse order (last painted = first hit)
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
child = parentData.previousSibling;
|
||||
}
|
||||
|
||||
// If no child was hit, the canvas itself handles the hit
|
||||
result.add(BoxHitTestEntry(this, ssHitPosition));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user