support for lazy building widgets within viewport
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)})';
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user