import 'dart:collection'; import 'package:flutter/material.dart'; import '../../utils/measure_size.dart'; import '../spatial_hashing.dart'; import '../../utils/offset_extensions.dart'; import '../../utils/size_extensions.dart'; import '../../utils/conversions.dart'; import '../../utils/styles.dart'; import '../render.dart'; part 'debug.dart'; part 'types.dart'; /// Controller for [LazyCanvas] class LazyCanvasController with ChangeNotifier { int _nextId = 0; // surely we won't run out of IDs, Clueless Offset _gsTopLeftOffset = Offset.zero; Offset? _lastProcessedOffset; double _baseScale = 1, _scale = 1; double? _lastProcessedScale; late Size _canvasSize; final HashMap _children = HashMap(); // int for IDs final Offset? _buildCacheExtent; late Offset _buildExtent; final Size _hashCellSize; bool _init = false; late final SpatialHashing _spatialHash; List _renderedChildrenCache = []; TickerProvider? _ticker; bool debug; LazyCanvasController({this.debug = false, Offset? buildCacheExtent, Size hashCellSize = const Size(100, 100)}) : _hashCellSize = hashCellSize, _buildCacheExtent = buildCacheExtent != null ? buildCacheExtent + Offset(50, 50) : null // only top left is considered so if a widget has long width, it'll not be rendered // unless the cache extent is sufficient { _spatialHash = SpatialHashing(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); bool get _cleanRenderState => _init && _lastProcessedOffset == _gsTopLeftOffset && _lastProcessedScale == _scale; // ==================== Callback Functions ==================== /// Update the canvas size when the widget size changes. void onCanvasSizeChange(Size size) { if (size == Size.zero) return; // ignore the zero side, linux first build pass error if (_init && size == _canvasSize) return; _buildExtent = Offset(size.width, size.height) + (_buildCacheExtent ?? Offset(size.width * 0.1, size.height * 0.1)); _canvasSize = size; // allow resize due to canvas resize // if the first init, re-render as not I have the canvas size to build widgets if (!_init) { _init = true; Future.microtask(notifyListeners); } } /// Called when a child widget's size changes. void onChildSizeChange(int id, Size size) { _children[id]!.lastRenderedSize = size; } /// Set the ticker provider for animations. void setTickerProvider(TickerProvider ticker) { _ticker = ticker; } // ==================== 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 notifyListeners(); 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); notifyListeners(); } /// Remove all children. Does not change where you are on the canvas. void clear() { _children.clear(); _spatialHash.clear(); _nextId = 0; _renderedChildrenCache.clear(); _lastProcessedOffset = null; _lastProcessedScale = null; notifyListeners(); } /// Update the position of a child by its ID. int updatePosition(int id, Offset newPosition) { if (_children.containsKey(id)) { _children[id]!.gsPosition = newPosition; notifyListeners(); 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 an additive 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 widgetsWithScreenPositions(BuildContext context, {bool forceRebuild = false}) { if (!_init) return []; if (_cleanRenderState && !forceRebuild) { return _renderedChildrenCache; } _lastProcessedOffset = _gsTopLeftOffset; _lastProcessedScale = _scale; final idsToBuild = _childrenWithinBuildArea(_gsCenter, _buildExtent); return _renderedChildrenCache = idsToBuild.map((id) { final item = _children[id]!; final ssPosition = gsToSs(item.gsPosition, _gsTopLeftOffset, _scale); var child = item.builder(context); if (debug) child = _Debug(id: id, gs: item.gsPosition, ss: ssPosition, child: child); return ChildInfo(id: id, gsPosition: item.gsPosition, ssPosition: ssPosition, child: child); }).toList(); } List _childrenWithinBuildArea(Offset center, Offset extent) { Offset halfExtent = Offset((extent.dx / (2 * _scale)).ceilToDouble(), (extent.dy / (2 * _scale)).ceilToDouble()); final items = _spatialHash.getPointsAround(center.toPoint(), halfExtent); return items.map((item) => item.data).toList(); // data is the child id here } // ==================== Centering & Focus Functions ==================== /// Center the canvas so that the given screen-space offset is at the center of the viewport. void centerOnScreenOffset(Offset ssOffset, {bool animate = true}) { centerOnGridOffset(ssToGs(ssOffset, _gsTopLeftOffset, _scale), animate: animate); } /// Center the canvas so that the given grid-space offset is at the center of the viewport. void centerOnGridOffset(Offset gsOffset, {bool animate = true}) { // if 2x scale you need to adjust lesser final newGsTopLeft = gsOffset + (canvasSize * (2 * scale)).toOffset(); if (animate) { animateToOffsetAndScale(offset: newGsTopLeft, scale: _scale); } else { _gsTopLeftOffset = newGsTopLeft; notifyListeners(); } } /// Focus the viewport on a child by its ID, with a margin in screen-space. /// If it's already rendered, size will be picked up from the child widget. If not /// an offstage rendering will be used ( double render ) /// Preferred horizontal margin used for [ScalingMode.fitInViewport]. void focusOnChild( BuildContext context, int id, { ScalingMode scalingMode = ScalingMode.keepScale, bool animate = true, double preferredHorizontalMargin = 16, Size? childSize, forceRedraw = false, }) { if (!_children.containsKey(id)) { throw _ChildNotFoundException; } final childInfo = _children[id]!; // try to figure out the size, take from render cache if available // else do an offstage render childSize ??= childInfo.lastRenderedSize != null && !forceRedraw ? childInfo.lastRenderedSize : measureWidgetSize(context, childInfo.builder); /* margin is symmatric on ltrb so 2mx + cx = screenWidth 2my + cy = screenHeight where c is child size in screen space and m is margin */ double newScale = _scale; Offset newGsTopLeft = _gsTopLeftOffset; switch (scalingMode) { case ScalingMode.keepScale: // do nothing break; case ScalingMode.resetScale: newScale = 1; case ScalingMode.fitInViewport: // the scale needs to be determined in this case // and hence a margin is needed to constrain on x, to get the scale, we then center it along y newScale = (canvasSize.width - 2 * preferredHorizontalMargin) / childSize!.width; break; } final marginOffset = ((canvasSize.toOffset() - (childSize! * scale).toOffset()) / (2 * scale)).makeAtleast(0); newGsTopLeft = childInfo.gsPosition - marginOffset; if (animate) { animateToOffsetAndScale(offset: newGsTopLeft, scale: newScale); // anim will notifyListeners } else { _gsTopLeftOffset = newGsTopLeft; _scale = newScale; notifyListeners(); } } // ==================== Animation ==================== /// Animate the canvas to a new offset and scale Future animateToOffsetAndScale({ required Offset offset, required double scale, Duration duration = const Duration(milliseconds: 300), Curve curve = Curves.easeInOut, }) async { final anim = AnimationController(vsync: _ticker!, duration: duration); final offsetTween = Tween(begin: _gsTopLeftOffset, end: offset); final scaleTween = Tween(begin: _scale, end: scale); final offsetAnimation = offsetTween.animate(CurvedAnimation(parent: anim, curve: curve)); final scaleAnimation = scaleTween.animate(CurvedAnimation(parent: anim, curve: curve)); anim.addListener(() { _gsTopLeftOffset = offsetAnimation.value; _scale = scaleAnimation.value; notifyListeners(); }); await anim.forward(); anim.dispose(); } }