canvas with panning

This commit is contained in:
2025-06-25 03:35:18 +00:00
parent 4b93580b3e
commit 6d0ef792db
6 changed files with 274 additions and 46 deletions
+53 -1
View File
@@ -9,8 +9,60 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> {
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),
),
);
}
}
-30
View File
@@ -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);
});
}
+30
View File
@@ -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);
}
}
+180
View File
@@ -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<Offset> positions;
final List<Widget> 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<Offset> 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<RenderBox> {}
class _CanvasRenderBox extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _CanvasParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _CanvasParentData> {
CanvasBackground canvasBackground;
Offset _offset;
double _scale;
List<Offset> _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<Offset> 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;
}
}
+2 -15
View File
@@ -1,15 +1,2 @@
import 'package:flutter/widgets.dart';
class InfiniteLazy2dGrid extends StatefulWidget {
const InfiniteLazy2dGrid({super.key});
@override
State<InfiniteLazy2dGrid> createState() => _InfiniteLazy2dGridState();
}
class _InfiniteLazy2dGridState extends State<InfiniteLazy2dGrid> {
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
export "./core/background.dart";
export "./core/render.dart";
+9
View File
@@ -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;
}
}