Created
April 4, 2026 02:59
-
-
Save shinayser/c49d4bdf00a7fa8131722778f5561d86 to your computer and use it in GitHub Desktop.
A Component that adds Pan and Zoom behavior to a PositionedComponent in Flame
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:ui'; | |
| import 'package:flame/components.dart'; | |
| import 'package:flame/events.dart'; | |
| import 'package:flame/rendering.dart'; | |
| /// A component that adds pan and zoom behavior to its parent | |
| /// [PositionComponent], simulating camera movement. | |
| /// | |
| /// ```dart | |
| /// final myComponent = SpriteComponent(/* ... */); | |
| /// myComponent.add(PanZoomComponent()); | |
| /// ``` | |
| /// | |
| /// On mount it injects a [Decorator] into the parent's decorator chain, | |
| /// so all siblings and the parent's own rendering are affected by pan/zoom. | |
| /// | |
| /// For mouse scroll zoom, call [applyZoom] from an | |
| /// external handler (e.g. [ScrollDetector] at the game level). | |
| class PanZoomComponent extends PositionComponent with DragCallbacks { | |
| PanZoomComponent({ | |
| double minZoom = 0.5, | |
| double maxZoom = 3.0, | |
| double zoomStep = 0.1, | |
| }) : _minZoom = minZoom, | |
| _maxZoom = maxZoom, | |
| _zoomStep = zoomStep; | |
| final double _minZoom; | |
| final double _maxZoom; | |
| final double _zoomStep; | |
| final Vector2 _panOffset = Vector2.zero(); | |
| double _zoom = 1.0; | |
| // Pointer tracking (positions in canvas space) | |
| final Map<int, Vector2> _pointers = {}; | |
| // Pinch state | |
| double _pinchStartDist = 0; | |
| double _zoomAtPinchStart = 1.0; | |
| final Vector2 _lastPinchCenter = Vector2.zero(); | |
| late final _PanZoomDecorator _decorator; | |
| // ── Configuration ───────────────────────────────────────────────── | |
| /// Minimum allowed zoom level. | |
| double get minZoom => _minZoom; | |
| /// Maximum allowed zoom level. | |
| double get maxZoom => _maxZoom; | |
| /// Zoom increment used by [zoomIn] / [zoomOut]. | |
| double get zoomStep => _zoomStep; | |
| // ── State (read-only) ────────────────────────────────────────────── | |
| /// Current pan offset (copy). | |
| Vector2 get panOffset => _panOffset.clone(); | |
| /// Current zoom level. | |
| double get currentZoom => _zoom; | |
| // ── Lifecycle ────────────────────────────────────────────────────── | |
| @override | |
| void onMount() { | |
| super.onMount(); | |
| assert( | |
| parent is PositionComponent, | |
| 'PanZoomComponent must be added to a PositionComponent', | |
| ); | |
| final parentComp = parent! as PositionComponent; | |
| size = parentComp.size.clone(); | |
| _decorator = _PanZoomDecorator(_panOffset, () => _zoom); | |
| parentComp.decorator.addLast(_decorator); | |
| } | |
| @override | |
| void onParentResize(Vector2 maxSize) { | |
| super.onParentResize(maxSize); | |
| size = maxSize.clone(); | |
| } | |
| @override | |
| void onRemove() { | |
| final parentComp = parent; | |
| if (parentComp is PositionComponent) { | |
| parentComp.decorator.removeLast(); | |
| } | |
| super.onRemove(); | |
| } | |
| // ── Gestures (DragCallbacks) ─────────────────────────────────────── | |
| @override | |
| void onDragStart(DragStartEvent event) { | |
| super.onDragStart(event); | |
| _pointers[event.pointerId] = event.canvasPosition.clone(); | |
| if (_pointers.length == 2) { | |
| _beginPinch(); | |
| } | |
| } | |
| @override | |
| void onDragUpdate(DragUpdateEvent event) { | |
| _pointers[event.pointerId] = event.canvasEndPosition.clone(); | |
| if (_pointers.length == 1) { | |
| // 1 finger → pan | |
| _panOffset.add(event.localDelta); | |
| } else if (_pointers.length >= 2) { | |
| // 2+ fingers → pinch (zoom + pan) | |
| _handlePinchUpdate(event); | |
| } | |
| } | |
| @override | |
| void onDragEnd(DragEndEvent event) { | |
| super.onDragEnd(event); | |
| _pointers.remove(event.pointerId); | |
| // Re-initialize pinch if back to exactly 2 fingers | |
| if (_pointers.length == 2) { | |
| _beginPinch(); | |
| } | |
| } | |
| // ── Internal pinch ──────────────────────────────────────────────── | |
| void _beginPinch() { | |
| _pinchStartDist = _pinchDistance(); | |
| _zoomAtPinchStart = _zoom; | |
| _lastPinchCenter.setFrom(_pinchCenter()); | |
| } | |
| void _handlePinchUpdate(DragUpdateEvent event) { | |
| final center = _pinchCenter(); | |
| final distance = _pinchDistance(); | |
| // Zoom by the distance ratio between fingers | |
| if (_pinchStartDist > 1.0) { | |
| final targetZoom = (_zoomAtPinchStart * distance / _pinchStartDist).clamp( | |
| minZoom, | |
| maxZoom, | |
| ); | |
| if (targetZoom != _zoom) { | |
| final focal = size / 2; | |
| final ratio = targetZoom / _zoom; | |
| _panOffset | |
| ..x = focal.x - (focal.x - _panOffset.x) * ratio | |
| ..y = focal.y - (focal.y - _panOffset.y) * ratio; | |
| _zoom = targetZoom; | |
| } | |
| } | |
| // Pan by the pinch center displacement. | |
| // Each finger contributes half the delta to the center; since we | |
| // receive one update per finger, we use half of the current finger's localDelta. | |
| final delta = event.localDelta; | |
| if (!delta.x.isNaN && !delta.y.isNaN) { | |
| _panOffset.add(delta * 0.5); | |
| } | |
| _lastPinchCenter.setFrom(center); | |
| } | |
| Vector2 _pinchCenter() { | |
| final pts = _pointers.values.toList(); | |
| return (pts[0] + pts[1]) / 2; | |
| } | |
| double _pinchDistance() { | |
| final pts = _pointers.values.toList(); | |
| return pts[0].distanceTo(pts[1]); | |
| } | |
| // ── Programmatic zoom ───────────────────────────────────────────── | |
| /// Changes zoom by [delta] around [focalPoint] (in the component's | |
| /// local coordinates). If [focalPoint] is `null`, uses the center. | |
| /// | |
| /// Useful for integrating with mouse scroll from the game: | |
| /// ```dart | |
| /// // In FlameGame: | |
| /// void onScroll(PointerScrollInfo info) { | |
| /// final delta = info.scrollDelta.global.y > 0 ? -0.1 : 0.1; | |
| /// myPanZoom.applyZoom(delta, focalPoint: info.eventPosition.widget); | |
| /// } | |
| /// ``` | |
| void applyZoom(double delta, {Vector2? focalPoint}) { | |
| final focal = focalPoint ?? (size / 2); | |
| final oldZoom = _zoom; | |
| _zoom = (_zoom + delta).clamp(minZoom, maxZoom); | |
| if (_zoom == oldZoom) return; | |
| final ratio = _zoom / oldZoom; | |
| _panOffset | |
| ..x = focal.x - (focal.x - _panOffset.x) * ratio | |
| ..y = focal.y - (focal.y - _panOffset.y) * ratio; | |
| } | |
| /// Zooms in by [zoomStep] around an optional [focalPoint]. | |
| void zoomIn({Vector2? focalPoint}) => | |
| applyZoom(zoomStep, focalPoint: focalPoint); | |
| /// Zooms out by [zoomStep] around an optional [focalPoint]. | |
| void zoomOut({Vector2? focalPoint}) => | |
| applyZoom(-zoomStep, focalPoint: focalPoint); | |
| // ── Coordinate conversion ────────────────────────────────────────── | |
| /// Converts a point from the component's local space to | |
| /// content space (accounting for pan and zoom). | |
| Vector2 localToContent(Vector2 localPoint) { | |
| return (localPoint - _panOffset) / _zoom; | |
| } | |
| /// Converts a point from content space to the component's local space. | |
| Vector2 contentToLocal(Vector2 contentPoint) { | |
| return contentPoint * _zoom + _panOffset; | |
| } | |
| // ── Utilities ────────────────────────────────────────────────────── | |
| /// Resets pan and zoom to default values (origin, 1×). | |
| void resetView() { | |
| _panOffset.setZero(); | |
| _zoom = 1.0; | |
| _pointers.clear(); | |
| } | |
| } | |
| /// Decorator that applies pan/zoom canvas transforms. | |
| /// | |
| /// Injected into the parent's decorator chain by [PanZoomComponent]. | |
| class _PanZoomDecorator extends Decorator { | |
| _PanZoomDecorator(this._panOffset, this._zoomGetter); | |
| final Vector2 _panOffset; | |
| final double Function() _zoomGetter; | |
| @override | |
| void apply(void Function(Canvas) draw, Canvas canvas) { | |
| canvas.save(); | |
| canvas.translate(_panOffset.x, _panOffset.y); | |
| final zoom = _zoomGetter(); | |
| canvas.scale(zoom, zoom); | |
| draw(canvas); | |
| canvas.restore(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment