Last active
April 4, 2026 02:58
-
-
Save shinayser/8f2d6a1b6fe61ea910b37884db4785c3 to your computer and use it in GitHub Desktop.
A mixin that adds Pan and Zoom behaviors to a PositionedComponent from Flame engine.
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'; | |
| /// Mixin that adds pan and zoom behavior to a [PositionComponent], | |
| /// simulating camera movement within the component. | |
| /// | |
| /// Uses [DragCallbacks] to capture pan (1 finger) and | |
| /// pinch-to-zoom (2+ fingers) gestures automatically on the component itself. | |
| /// | |
| /// ```dart | |
| /// class MyViewport extends PositionComponent | |
| /// with DragCallbacks, PanZoomMixin { | |
| /// // Children added normally will be affected by pan/zoom. | |
| /// } | |
| /// ``` | |
| /// | |
| /// For mouse scroll zoom, call [applyZoom] from an | |
| /// external handler (e.g. [ScrollDetector] at the game level). | |
| mixin PanZoomMixin on PositionComponent, DragCallbacks { | |
| 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(); | |
| // ── Configuration (override to customize) ────────────────────────── | |
| /// Minimum allowed zoom level. | |
| double get minZoom => 0.5; | |
| /// Maximum allowed zoom level. | |
| double get maxZoom => 3.0; | |
| /// Zoom increment used by [zoomIn] / [zoomOut]. | |
| double get zoomStep => 0.1; | |
| // ── State (read-only) ────────────────────────────────────────────── | |
| /// Current pan offset (copy). | |
| Vector2 get panOffset => _panOffset.clone(); | |
| /// Current zoom level. | |
| double get currentZoom => _zoom; | |
| // ── Rendering ────────────────────────────────────────────────────── | |
| /// Applies the pan/zoom transform before rendering content. | |
| /// | |
| /// The canvas operations here propagate to children, since they are | |
| /// rendered within the same save/restore block created by | |
| /// [PositionComponent.renderTree]. | |
| @override | |
| void render(Canvas canvas) { | |
| canvas.translate(_panOffset.x, _panOffset.y); | |
| canvas.scale(_zoom, _zoom); | |
| super.render(canvas); | |
| } | |
| // ── 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; | |
| /// myComponent.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(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment