support for lazy building widgets within viewport

This commit is contained in:
2025-07-05 20:09:52 +00:00
parent 7cfb9457a2
commit b81b99c55c
10 changed files with 392 additions and 194 deletions
-1
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:infinite_lazy_2d_grid/core/controller.dart';
import 'package:infinite_lazy_2d_grid/infinite_lazy_2d_grid.dart';
class App extends StatefulWidget {
-152
View File
@@ -1,152 +0,0 @@
import 'package:flutter/material.dart';
import 'package:infinite_lazy_2d_grid/utils/conversions.dart';
import 'package:infinite_lazy_2d_grid/utils/styles.dart';
// ------------------------------ Public Types -------------------------------
typedef WidgetBuilder = Widget Function();
// ------------------------------ Private Types ------------------------------
// some simple exceptions
// ignore: non_constant_identifier_names
final _ChildNotFoundException = Exception('Child with the given ID does not exist');
class _ChildInfo {
Offset gsPosition;
final WidgetBuilder 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 ------------------------------
/// Every canvas view needs one and this handles the positioning logic
class CanvasController with ChangeNotifier {
int _nextId = 0; // surely we won't run out of IDs, Clueless
Offset _gsTopLeftOffset = Offset.zero;
double _baseScale, _scale;
late Size _canvasSize;
final Map<int, _ChildInfo> _children = {}; // int for IDs
bool debug;
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] = _ChildInfo(gsPosition: position, builder: builder);
return _nextId++;
}
void removeChild(int id) {
_children.remove(id);
}
int updatePosition(int id, Offset newPosition) {
if (_children.containsKey(id)) {
_children[id]!.gsPosition = newPosition;
return id;
} else {
throw _ChildNotFoundException;
}
}
Offset getPosition(int id) {
if (_children.containsKey(id)) {
return _children[id]!.gsPosition;
} else {
throw _ChildNotFoundException;
}
}
void onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
}
// -------- gesture handling --------
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
// 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();
}
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, _scale);
var child = item.builder();
if (debug) child = _Debug(id: entry.key, gs: item.gsPosition, ss: ssPosition, child: child);
return ChildInfo(gsPosition: item.gsPosition, ssPosition: ssPosition, child: child);
}).toList();
}
}
class _Debug extends StatelessWidget {
final int id;
final Offset gs, ss;
final Widget child;
const _Debug({required this.id, required this.gs, required this.ss, required this.child});
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
left: 0,
bottom: -60,
child: Text(
'ID: $id\nGS:(${gs.dx.toInt()},${gs.dy.toInt()})\nSS:(${ss.dx.toInt()},${ss.dy.toInt()})',
style: monospaceStyle,
),
),
],
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:infinite_lazy_2d_grid/core/spatial_hashing.dart';
import 'package:infinite_lazy_2d_grid/utils/offset_extensions.dart';
import '../../utils/conversions.dart';
import '../../utils/styles.dart';
part 'debug.dart';
part 'types.dart';
/// Every canvas view needs one and this handles the positioning logic
class CanvasController with ChangeNotifier {
int _nextId = 0; // surely we won't run out of IDs, Clueless
Offset _gsTopLeftOffset = Offset.zero;
double _baseScale, _scale;
late Size _canvasSize;
final Map<int, _ChildInfo> _children = {}; // int for IDs
final Offset? buildCacheExtent;
late final Offset _buildExtent;
final Offset _initialBuildExtent;
final Size _hashCellSize;
bool _init = false;
late final SpatialHashing<int> _spatialHash;
bool debug;
CanvasController({
double initialScale = 1,
this.debug = false,
this.buildCacheExtent,
Size hashCellSize = const Size(200, 200),
Offset initialBuildExtent = const Offset(1000, 1000),
}) : _scale = initialScale,
_baseScale = initialScale,
_hashCellSize = hashCellSize,
_initialBuildExtent = initialBuildExtent,
assert(initialScale > 0, 'Initial scale must be greater than 0') {
_spatialHash = SpatialHashing<int>(cellSize: _hashCellSize);
}
// ==================== Getters ====================
Offset get offset => _gsTopLeftOffset;
double get scale => _scale;
Size get canvasSize => _canvasSize;
Offset get _ssCenter => Offset(_canvasSize.width / 2, _canvasSize.height / 2);
Offset get _gsCenter => ssToGs(_ssCenter, _gsTopLeftOffset, _scale);
// ==================== Public Functions ====================
/// Update the canvas size when the widget size changes.
void onCanvasSizeChange(Size size) {
if (_init && size == _canvasSize) return;
_canvasSize = size; // allow resize due to canvas resize
if (!_init) {
_buildExtent =
Offset(size.width, size.height) + (buildCacheExtent ?? Offset(size.width * 0.5, size.height * 0.5));
_init = true;
}
}
// ==================== Child Management ====================
/// Add a child at a given position with a builder. Returns the child ID.
int addChild(Offset position, WidgetBuilder builder) {
_children[_nextId] = _ChildInfo(gsPosition: position, builder: builder);
_spatialHash.add(position.toPoint(), _nextId); // add to spatial hash
return _nextId++;
}
/// Remove a child by its ID.
void removeChild(int id) {
if (!_children.containsKey(id)) {
throw _ChildNotFoundException;
}
_spatialHash.remove(_children[id]!.gsPosition.toPoint());
_children.remove(id);
}
/// Update the position of a child by its ID.
int updatePosition(int id, Offset newPosition) {
if (_children.containsKey(id)) {
_children[id]!.gsPosition = newPosition;
return id;
} else {
throw _ChildNotFoundException;
}
}
/// Get the position of a child by its ID.
Offset getPosition(int id) {
if (_children.containsKey(id)) {
return _children[id]!.gsPosition;
} else {
throw _ChildNotFoundException;
}
}
// ==================== Gesture Handling ====================
/// Called when a scale gesture starts.
void onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
}
/// Called when a scale gesture updates.
/// Usually you would not want to override this
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
// scale + offset => 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();
}
/// Increment or decrement the scale by a delta value.
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();
}
// ==================== Positioning Logic ====================
/// Currently rendered widgets with their position info
List<ChildInfo> widgetsWithScreenPositions() {
final idsToBuild = _init
? _childrenWithinBuildArea(_gsCenter, _buildExtent)
: _childrenWithinBuildArea(Offset.zero, _initialBuildExtent);
return idsToBuild.map((id) {
final item = _children[id]!;
final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale);
var child = item.builder();
if (debug) child = _Debug(id: id, gs: item.gsPosition, ss: ssPosition, child: child);
return ChildInfo(gsPosition: item.gsPosition, ssPosition: ssPosition, child: child);
}).toList();
}
List<int> _childrenWithinBuildArea(Offset center, Offset extent) {
final items = _spatialHash.getPointsAround(center.toPoint(), extent);
return items.map((item) => item.data).toList(); // data is the child id here
}
}
+27
View File
@@ -0,0 +1,27 @@
part of 'controller.dart';
class _Debug extends StatelessWidget {
final int id;
final Offset gs, ss;
final Widget child;
const _Debug({required this.id, required this.gs, required this.ss, required this.child});
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
left: 0,
bottom: -60,
child: Text(
'ID: $id\nGS:(${gs.dx.toInt()},${gs.dy.toInt()})\nSS:(${ss.dx.toInt()},${ss.dy.toInt()})',
style: monospaceStyle,
),
),
],
);
}
}
+23
View File
@@ -0,0 +1,23 @@
part of 'controller.dart';
typedef WidgetBuilder = Widget Function();
// ------------------------------ Private Types ------------------------------
// some simple exceptions
// ignore: non_constant_identifier_names
final _ChildNotFoundException = Exception('Child with the given ID does not exist');
class _ChildInfo {
Offset gsPosition;
final WidgetBuilder 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});
}
+5 -6
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:infinite_lazy_2d_grid/core/controller.dart';
import 'package:infinite_lazy_2d_grid/core/controller/controller.dart';
import 'package:infinite_lazy_2d_grid/utils/offset_extensions.dart';
import 'package:infinite_lazy_2d_grid/utils/styles.dart';
@@ -15,9 +15,7 @@ class CanvasView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: canvasBackground.bgColor,
child: GestureDetector(
return GestureDetector(
behavior: HitTestBehavior.translucent,
onScaleUpdate: controller.onScaleUpdate,
onScaleStart: controller.onScaleStart,
@@ -54,7 +52,6 @@ class CanvasView extends StatelessWidget {
}
},
),
),
);
}
}
@@ -122,7 +119,9 @@ class _CanvasRenderBox extends RenderBox
}
set ssPositions(List<Offset> ssPositions) {
assert(ssPositions.length == childCount);
// at this point childCount may not be equal to ssPositions.length
// but since we are only marking for layout this is fine
// at the actual performLayout this will be asserted
if (_ssPositions != ssPositions) {
_ssPositions = ssPositions;
markNeedsLayout();
+74
View File
@@ -0,0 +1,74 @@
import 'dart:collection';
import 'dart:math';
import 'package:flutter/material.dart';
typedef _CellKey = Point<int>;
class PointData<T> {
final Point point;
final T data;
PointData(this.point, this.data);
}
class SpatialHashing<T> {
final Size cellSize;
final HashMap<Point, T> _pointData = HashMap<Point, T>();
final HashMap<_CellKey, List<Point>> _cellMap = HashMap<_CellKey, List<Point>>();
SpatialHashing({required this.cellSize});
_CellKey _cellKey(Point point) {
int x = (point.x / cellSize.width).floor();
int y = (point.y / cellSize.height).floor();
return Point(x, y);
}
void add(Point point, T data) {
_CellKey cellKey = _cellKey(point);
_pointData[point] = data;
if (!_cellMap.containsKey(cellKey)) {
_cellMap[cellKey] = [];
}
_cellMap[cellKey]!.add(point);
}
void remove(Point point) {
final key = _cellKey(point);
if (_pointData.containsKey(point)) {
_pointData.remove(point); // remove the data
// cell map must have key then
_cellMap[key]!.remove(point); // remove from spatial hash
if (_cellMap[key]!.isEmpty) {
_cellMap.remove(key);
}
}
}
List<PointData<T>> getPointsAround(Point point, Offset offset) {
_CellKey cellKey = _cellKey(point);
List<PointData<T>> results = [];
// Calculate the range of cells to check
int startX = cellKey.x - (offset.dx / cellSize.width).floor();
int startY = cellKey.y - (offset.dy / cellSize.height).floor();
int endX = cellKey.x + (offset.dx / cellSize.width).ceil();
int endY = cellKey.y + (offset.dy / cellSize.height).ceil();
for (int x = startX; x <= endX; x++) {
for (int y = startY; y <= endY; y++) {
Point currentCellKey = Point(x, y);
if (_cellMap.containsKey(currentCellKey)) {
for (Point p in _cellMap[currentCellKey]!) {
results.add(PointData(p, _pointData[p] as T));
}
}
}
}
return results;
}
}
+3 -2
View File
@@ -1,2 +1,3 @@
export "./core/background.dart";
export "./core/render.dart";
export "core/background.dart";
export "core/render.dart";
export 'core/controller/controller.dart';
+5
View File
@@ -1,3 +1,4 @@
import 'dart:math';
import 'dart:ui' show Offset;
extension OffsetCartesionOps on Offset {
@@ -11,6 +12,10 @@ extension OffsetCartesionOps on Offset {
return Offset(dx - other.dx, dy - other.dy);
}
Point toPoint() {
return Point(dx, dy);
}
String coord() {
return '(${dx.toStringAsFixed(2)}, ${dy.toStringAsFixed(2)})';
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:infinite_lazy_2d_grid/core/spatial_hashing.dart';
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
group('SpatialHashing', () {
late SpatialHashing<String> spatialHashing;
setUp(() {
spatialHashing = SpatialHashing<String>(cellSize: const Size(10, 10));
});
test('add and retrieve single point', () {
final point = Point(5, 5);
spatialHashing.add(point, 'A');
final results = spatialHashing.getPointsAround(point, const Offset(0, 0));
expect(results.length, 1);
expect(results.first, (point, 'A'));
});
test('remove point', () {
final point = Point(5, 5);
spatialHashing.add(point, 'A');
spatialHashing.remove(point);
final results = spatialHashing.getPointsAround(point, const Offset(0, 0));
expect(results, isEmpty);
});
test('add multiple points in same cell', () {
final p1 = Point(2, 2);
final p2 = Point(7, 7);
spatialHashing.add(p1, 'A');
spatialHashing.add(p2, 'B');
final results = spatialHashing.getPointsAround(p1, const Offset(0, 0));
expect(results.length, 2);
expect(results, contains((p1, 'A')));
expect(results, contains((p2, 'B')));
});
test('add points in different cells and query with offset', () {
final p1 = Point(5, 5);
final p2 = Point(15, 15);
spatialHashing.add(p1, 'A');
spatialHashing.add(p2, 'B');
// Query with offset to include both cells
final results = spatialHashing.getPointsAround(p1, const Offset(10, 10));
expect(results.length, 2);
expect(results, contains((p1, 'A')));
expect(results, contains((p2, 'B')));
});
test('removing one of multiple points in a cell', () {
final p1 = Point(2, 2);
final p2 = Point(7, 7);
spatialHashing.add(p1, 'A');
spatialHashing.add(p2, 'B');
spatialHashing.remove(p1);
final results = spatialHashing.getPointsAround(p2, const Offset(0, 0));
expect(results.length, 1);
expect(results.first, (p2, 'B'));
});
});
}