air_pointer 0.2.2
air_pointer: ^0.2.2 copied to clipboard
Platform-agnostic canvas input for Flutter. Unifies mouse, trackpad, and MediaPipe hand-gesture events behind a single CanvasInputController.
air_pointer #
Platform-agnostic canvas input abstraction for Flutter. Ships a MouseInputSource
for mouse/trackpad/touch and a GestureInputSource powered by MediaPipe
HandLandmarker for touchless hand control (Flutter Web only), both unified behind
a single CanvasInputController — the canvas never knows which source the events
came from.
Quick start #
import 'package:air_pointer/air_pointer.dart';
class MyCanvasState extends State<MyCanvas> {
late final CanvasInputController _controller;
late final StreamSubscription<PointerInputEvent> _sub;
@override
void initState() {
super.initState();
_controller = CanvasInputController(
sources: [
MouseInputSource(), // mouse, trackpad, touch
GestureInputSource(), // MediaPipe hand tracking (web only; no-op elsewhere)
],
);
_sub = _controller.events.listen(_onInput);
}
void _onInput(PointerInputEvent event) {
switch (event) {
case CanvasTapEvent(:final position):
// resolved tap (no drag)
case CanvasDoubleTapEvent(:final position):
// second tap within the double-tap window (~300 ms)
// always preceded by CanvasTapEvent on the same frame
case CanvasLongPressEvent(:final position):
// pointer held still beyond the long-press threshold
case CanvasDownEvent(:final position):
// drag/pinch started
case CanvasMoveEvent(:final position):
// drag in progress
case CanvasUpEvent(:final position):
// released — commit the action
case CanvasCancelEvent():
// drag interrupted (e.g. hand left the camera mid-drag) — discard it
case CanvasHoverEvent(:final position):
// cursor hovering (no button held)
case CanvasScrollEvent(:final position, :final delta, :final isTrackpad):
// scroll wheel or pointing-finger scroll
case CanvasScaleEvent(:final focalPoint, :final scaleDelta, :final panDelta):
// pinch-to-zoom (two fingers or two hands)
case CanvasScaleEndEvent():
// scale gesture ended
case CanvasSwipeEvent(:final direction, :final velocity):
// fast directional motion in an open hand
case CanvasGestureEvent(:final gesture, :final isSecondHand):
// discrete gesture recognised (thumbsUp, victory, etc.)
// isSecondHand is true when it came from the second detected hand
}
}
@override
Widget build(BuildContext context) => _controller.buildSurface(
child: MyCanvasPainter(...),
);
@override
void dispose() {
unawaited(_sub.cancel());
unawaited(_controller.dispose());
super.dispose();
}
}
Event type reference #
| Type | Fields | Description |
|---|---|---|
CanvasTapEvent |
position |
Resolved tap (no drag) |
CanvasDoubleTapEvent |
position |
Second tap within the double-tap window; always preceded by CanvasTapEvent |
CanvasLongPressEvent |
position |
Pointer held still beyond the long-press threshold |
CanvasDownEvent |
position |
Drag/pinch started |
CanvasMoveEvent |
position |
Drag in progress |
CanvasUpEvent |
position |
Drag ended — commit |
CanvasCancelEvent |
— | Drag aborted — discard |
CanvasHoverEvent |
position |
Hover (no press) |
CanvasScrollEvent |
position, delta, isTrackpad |
Scroll wheel or pointing-finger scroll |
CanvasScaleEvent |
focalPoint, scaleDelta, panDelta, rotation |
Pinch/spread with optional rotation |
CanvasScaleEndEvent |
— | Scale gesture ended |
CanvasSwipeEvent |
direction, velocity |
Fast directional cursor movement |
CanvasGestureEvent |
gesture, isSecondHand |
Discrete hand gesture (edge-triggered on change) |
GestureInputSource (Flutter Web only) #
1. Add the MediaPipe CDN script #
In web/index.html, before flutter_bootstrap.js:
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.21/vision_bundle.js"
crossorigin="anonymous"></script>
<script src="flutter_bootstrap.js" async></script>
2. Copy the worker script #
Place hand_tracker_worker.js (from example/web/) next to your
index.html. The worker runs MediaPipe inference off the main thread.
3. Initialize the source #
final _gestureSource = GestureInputSource(
onError: (e, st) => print('hand tracking: $e'),
dwellDuration: const Duration(milliseconds: 700), // enable dwell-tap
doubleTapWindow: const Duration(milliseconds: 300),
longPressDuration: const Duration(milliseconds: 1200),
scrollEnabled: true,
maxHands: 2, // set to 1 for better performance in single-hand apps
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Pass the canvas size so cursor coordinates are in screen pixels.
_gestureSource.updateCanvasSize(MediaQuery.sizeOf(context));
unawaited(_gestureSource.initialize());
}
initialize() is idempotent — safe to call from didChangeDependencies.
Gesture mapping #
| Hand gesture | Event |
|---|---|
| Open hand, fingertip moves | CanvasHoverEvent |
| Pinch (thumb + index < 5 % gap) | CanvasDownEvent |
| Hold pinch + move | CanvasMoveEvent |
| Release pinch | CanvasUpEvent |
| Hand exits frame mid-drag | CanvasCancelEvent (discard, not commit) |
Cursor holds still for dwellDuration |
CanvasTapEvent |
Second dwell within doubleTapWindow |
CanvasTapEvent + CanvasDoubleTapEvent |
Cursor holds still for longPressDuration |
CanvasLongPressEvent |
| Index finger extended, move up/down | CanvasScrollEvent (when scrollEnabled: true) |
| Fast directional motion, open hand | CanvasSwipeEvent (when swipeThreshold > 0) |
| Two-hand spread/pinch | CanvasScaleEvent with rotation |
| Two hands separate | CanvasScaleEndEvent |
| Recognised discrete gesture | CanvasGestureEvent (edge-triggered; isSecondHand for the second hand) |
Position is smoothed with a OneEuroFilter and the x-axis is mirrored so
motion feels natural facing the front camera.
Dwell-tap #
Dwell-tap lets users click without a physical button — the cursor fires
CanvasTapEvent after holding still within dwellRadius pixels for dwellDuration:
GestureInputSource(
dwellDuration: const Duration(milliseconds: 700),
dwellRadius: 12.0,
)
GestureDebugInfo.dwellProgress (0–1) drives a progress ring in the example app.
Progress is preserved through the grace window so brief occlusions don't reset it.
Double-tap and long-press #
Both GestureInputSource and MouseInputSource fire the same events:
GestureInputSource(
dwellDuration: const Duration(milliseconds: 500),
doubleTapWindow: const Duration(milliseconds: 300), // time between two taps
longPressDuration: const Duration(milliseconds: 1200),
)
MouseInputSource() // double-tap via DateTime gap; long-press via GestureDetector (~500 ms)
CanvasDoubleTapEvent is always preceded by CanvasTapEvent on the same frame so
consumers that only handle single tap continue to work.
Discrete gesture events #
CanvasGestureEvent fires once when a RecognizedGesture value first appears,
then rearms when the hand is lost so re-entry with the same gesture fires again:
case CanvasGestureEvent(:final gesture, :final isSecondHand):
if (!isSecondHand) {
switch (gesture) {
case RecognizedGesture.thumbUp: _undo();
case RecognizedGesture.victory: _redo();
case RecognizedGesture.openPalm: _showMenu();
default: break;
}
}
Supported values: thumbUp, thumbDown, openPalm, closedFist, victory,
pointingUp, iLoveYou, none.
Camera preview #
_gestureSource.buildCameraPreview(width: 240, height: 180)
Returns a widget with a live mirrored feed. Shows a placeholder until the camera is ready, and an error card if initialization failed.
Debug overlay #
Subscribe to GestureInputSource.debugInfo for per-frame GestureDebugInfo
snapshots (phase, pinch distance, dwell progress, landmarks, detected gestures,
worker latency). Use them to build a custom debug overlay — the example app
(example/) has a ready-made one.
MouseInputSource options #
MouseInputSource(
tapSlop: 10.0, // max displacement (px) still treated as a tap
scrollMultiplier: 1.0, // scale scroll deltas (e.g. 1/zoomLevel)
)
CanvasScrollEvent.isTrackpad is true for macOS/iOS two-finger pan; the OS
already applies momentum so consumers should skip extra inertia for those events.
Double-tap is detected via a 300 ms gap between tap releases. Long-press uses
Flutter's GestureDetector.onLongPressStart (~500 ms threshold). If a drag was
in progress when long-press fires, CanvasCancelEvent is emitted first.
TouchInputSource (Android / iOS) #
MouseInputSource works on mobile but maps single-finger drag to element-drag
events (CanvasDownEvent/CanvasMoveEvent/CanvasUpEvent). TouchInputSource
uses direct-manipulation semantics: finger drags pan the canvas, pinch zooms it.
Use defaultPointerSource() to pick the right source automatically (see below),
or wire it manually:
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform;
_controller = CanvasInputController(
sources: [
if (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS)
TouchInputSource() // mobile: pan + pinch
else
MouseInputSource(), // desktop / web: drag + scroll
GestureInputSource(), // hand tracking on web; no-op elsewhere
],
);
Gesture mapping #
| Touch gesture | Event |
|---|---|
| Tap | CanvasTapEvent |
| Double-tap | CanvasTapEvent + CanvasDoubleTapEvent |
| Long press | CanvasLongPressEvent |
| Single-finger drag | CanvasScrollEvent (pan) |
| Flick / fling | CanvasScrollEvent with velocity (seed momentum) |
| Two-finger pinch or spread | CanvasScaleEvent |
| Two-finger release | CanvasScaleEndEvent |
Delta and velocity conventions #
CanvasScrollEvent.delta follows the same sign convention as mouse scroll:
offset -= delta pans the canvas in the direction the finger moved.
On fling, delta is Offset.zero and velocity (pixels/second) is non-zero
in the direction the content should continue moving. Use it to seed a ticker:
case CanvasScrollEvent(:final delta, :final velocity, :final isTrackpad):
if (velocity != Offset.zero) {
_momentum = velocity;
_momentumTicker.start();
} else {
_offset -= delta;
setState(() {});
}
Options #
TouchInputSource(
tapSlop: 10.0, // max px displacement still treated as a tap
doubleTapWindow: const Duration(milliseconds: 300),
)
StylusInputSource (Apple Pencil / S-Pen) #
StylusInputSource handles PointerDeviceKind.stylus and
PointerDeviceKind.invertedStylus (eraser end). It uses the same element-drag
semantics as MouseInputSource and filters out mouse/touch/trackpad events so it
can be combined with other sources without emitting duplicate events.
final _stylus = StylusInputSource();
_controller = CanvasInputController(
sources: [
_stylus, // Apple Pencil / S-Pen: drag, tap, hover
TouchInputSource(), // finger touch: pan + pinch
],
);
// Distinguish pen tip from eraser
_stylus.eraserModeStream.listen((isEraser) {
setState(() => _isEraserMode = isEraser);
});
Gesture mapping #
| Pen contact | Event |
|---|---|
| Tap (minimal movement) | CanvasTapEvent |
| Double-tap | CanvasTapEvent + CanvasDoubleTapEvent |
| Pen drag | CanvasDownEvent → CanvasMoveEvent → CanvasUpEvent |
| Hover (proximity) | CanvasHoverEvent |
| OS pointer cancel | CanvasCancelEvent |
Pressure #
CanvasDownEvent.pressure and CanvasMoveEvent.pressure carry the hardware
value (0.0–1.0). All other input sources emit the default 1.0.
case CanvasMoveEvent(:final position, :final pressure):
_brush.draw(position, size: pressure * _maxBrushSize);
Eraser detection #
StylusInputSource.eraserModeStream emits true when the eraser end contacts
the screen and false when the pen tip is active. It only emits on mode changes,
not every frame.
Options #
StylusInputSource(
tapSlop: 8.0, // max px displacement still treated as a tap (default)
doubleTapWindow: const Duration(milliseconds: 300),
)
defaultPointerSource() #
Returns the best-fit CanvasInputSource for the current platform without any
manual platform checks:
_controller = CanvasInputController(
sources: [defaultPointerSource()],
);
| Platform | Source returned |
|---|---|
| Android, iOS | TouchInputSource |
| Web | MouseInputSource |
| macOS, Windows, Linux | MouseInputSource |
Uses kIsWeb and defaultTargetPlatform from package:flutter/foundation.dart
(not dart:io), so it is safe to call on web.
GestureInputSource is intentionally not included — add it separately when you
need air-gesture input:
_controller = CanvasInputController(
sources: [
defaultPointerSource(),
GestureInputSource(), // web: MediaPipe; native: no-op stub
],
);
Muting a source when another is active #
CanvasInputController can suppress a source whenever a second source is
actively delivering events — for example, mute the mouse when the hand tracker
has a confirmed hand in frame:
final _active = StreamController<bool>.broadcast();
_controller = CanvasInputController(
sources: [MouseInputSource(), _gestureSource],
muteWhenActive: {MouseInputSource()}, // suppress this source …
activeStream: _active.stream, // … while this stream emits true
);
// Drive it from the statusStream:
_gestureSource.statusStream.listen((status) {
_active.add(status is HandTrackingTracking);
});
Both muteWhenActive and activeStream must be provided together or not at all.
Tracking lifecycle (statusStream) #
GestureInputSource.statusStream emits typed HandTrackingStatus states:
initializing → cameraReady → tracking ⇄ lost
↘ error (terminal)
_gestureSource.statusStream.listen((status) {
switch (status) {
case HandTrackingInitializing(): // camera + model loading
case HandTrackingCameraReady(): // camera live; scanning for hands
case HandTrackingTracking(): // hand confirmed in frame
case HandTrackingLost(): // hand left the frame
case HandTrackingError(:final error): // unrecoverable — check onError
}
});
HandTrackingError is terminal; no further tracking/lost states are emitted
after it. Listen to onError for the underlying StateError message.
Runtime filter tuning #
Adjust cursor smoothing at any time without reconstructing the source:
_gestureSource.setFilterParams(
minCutoff: 0.5, // lower = smoother, more lag (default 1.0)
beta: 0.1, // higher = less lag during fast motion (default 0.05)
predictionHorizon: const Duration(milliseconds: 16), // look-ahead to offset filter lag
);
Call between tracking sessions for smoothest results — a mid-session call resets the filter state and may cause a brief cursor jump on the next frame.
Per-user calibration #
Default thresholds (pinch close = 0.05, open = 0.08) work for most hands in good lighting. For users with small hands, unusual skin tone, or dim environments, run a quick calibration:
// 1. Collect samples via GestureCalibrator (reads pinch distance from debugInfo).
final calibrator = GestureCalibrator();
// Call addOpenSample / addCloseSample each frame from your debugInfo subscription:
// calibrator.addOpenSample(info.pinchDistance); // while hand is open
// calibrator.addCloseSample(info.pinchDistance); // while hand is pinching
// 2. Compute thresholds when both poses are collected.
final result = calibrator.compute(); // null if insufficient data
if (result != null) {
gestureSource.applyCalibration(result);
}
The example app ships a CalibrationDialog widget that handles the full
guided flow.
Native hand tracking (non-web) #
On iOS, Android, macOS, Windows, and Linux the web worker is unavailable.
Use the LandmarkProvider interface to connect any native ML backend:
class MyTFLiteProvider implements LandmarkProvider {
final _ctrl = StreamController<HandDetectionFrame>.broadcast();
@override
Stream<HandDetectionFrame> get frames => _ctrl.stream;
void onInferenceResult(List<HandLandmarkPoint> lms) {
_ctrl.add(HandDetectionFrame(landmarks: lms));
}
@override
Widget buildPreview({double? width, double? height}) => CameraPreview(...);
@override
void dispose() { _ctrl.close(); }
}
final source = GestureInputSource(
landmarkProvider: MyTFLiteProvider(),
maxHands: 1, // query source.maxHands to configure your provider
dwellDuration: const Duration(milliseconds: 700),
);
HandGestureRecognizer is the pure-Dart state machine that drives all event
logic — it is fully unit-testable without a camera or ML model.
Self-hosting MediaPipe assets (Flutter Web only) #
By default GestureInputSource loads the WASM runtime and hand model from CDN.
For offline or CSP-restricted deployments, self-host both assets:
GestureInputSource(
mediaPipeBaseUrl: '/mediapipe/', // serves vision_bundle.js + WASM files
modelAssetUrl: '/mediapipe/hand_landmarker.task',
)
Use scripts/download_mediapipe.sh to fetch the pinned versions into
example/web/mediapipe/. Both parameters are Flutter Web only — they have
no effect on native builds and do not exist on GestureInputSource native stub.
Architecture boundary #
The strict invariant: no NormalizedLandmark, HandLandmarker, JSObject,
or any dart:js_interop type may appear outside lib/src/gesture/. The
PointerInputEvent sealed hierarchy is the only currency that crosses the
boundary — all sources speak the same type.
Known limitations #
-
Flutter Web only for MediaPipe.
GestureInputSourcerequires aLandmarkProvideron native platforms. The web implementation is self-contained (MediaPipe via web worker); native requires you to wire up your own ML backend. -
Requires a secure context.
getUserMediaonly works onhttps://orlocalhost. Serving over plainhttp://will produce a camera permission error. -
Lighting sensitivity. Tracking degrades in dim or strongly backlit conditions. MediaPipe's model is generally robust across skin tones but performance may vary. Run calibration if default thresholds are unreliable.
-
First-run CDN latency without self-hosting. The WASM runtime (~4 MB) and
.taskmodel file load from CDN on first page load. Self-host by passingmediaPipeBaseUrlandmodelAssetUrltoGestureInputSource(web only — see the self-hosting section below) or usingscripts/download_mediapipe.sh. -
First-run CDN latency. MediaPipe WASM (~4 MB) loads before the first frame is processed. On a cold cache this takes 2–5 seconds. Use self-hosting (see above) to eliminate the CDN dependency and reduce cold-start time.
-
CanvasCancelEventhas no position. When a hand exits the frame mid-drag, the last known position is not re-emitted. Consumers that need a "cancel at position" snapshot should cache_lastPositionfrom the precedingCanvasMoveEvent.