Skip to content

Instantly share code, notes, and snippets.

@shinayser
Created April 4, 2026 02:59
Show Gist options
  • Select an option

  • Save shinayser/c49d4bdf00a7fa8131722778f5561d86 to your computer and use it in GitHub Desktop.

Select an option

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
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