Skip to content

Instantly share code, notes, and snippets.

@shinayser
Last active April 4, 2026 02:58
Show Gist options
  • Select an option

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

Select an option

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