canvas with panning
This commit is contained in:
+53
-1
@@ -9,8 +9,60 @@ class App extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppState extends State<App> {
|
class _AppState extends State<App> {
|
||||||
|
Offset _dragStartFocalPoint = Offset.zero;
|
||||||
|
Offset _lastFocalPoint = Offset.zero;
|
||||||
|
Offset _offset = Offset.zero;
|
||||||
|
double _scale = 1.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,2 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
export "./core/background.dart";
|
||||||
|
export "./core/render.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user