Custom Interactive Viewer
A Flutter camera and input package for images, documents, maps, whiteboards and diagrams. Supports mouse, touch, trackpad, wheel and keyboard input, focal-point zoom, rotation, camera animation and configurable bounds.
Requires Flutter 3.41 / Dart 3.11 or newer.
dependencies:
custom_interactive_viewer: ^0.1.0
Finite content
CustomInteractiveViewer(
child: Image.asset('assets/map.png'),
zoomConfig: const ZoomConfig(minScale: .1, maxScale: 8),
)
The ordinary constructor measures its child and uses directional alignment.
contentSize or contentSizeGetter can override measurement. Use
InteractionConfig(constrainBounds: true) to constrain finite content. Existing
finite-content usage remains supported; see migration notes
for corrected lifecycle and animation behavior.
A world canvas
final camera = CustomInteractiveViewerController(
initialOffset: const Offset(300, 200),
);
CustomInteractiveViewer.world(
controller: camera,
viewportBuilder: (context, viewport) {
// This point can be anywhere, including left/above the origin.
final anchor = viewport.worldToViewport(const Offset(-100, -50));
return Stack(children: [
Positioned(
left: anchor.dx,
top: anchor.dy,
child: const Text('A world-space annotation'),
),
]);
},
)
The builder receives a viewport-sized area and an immutable ViewerViewport.
It supplies worldToViewport, viewportToWorld, worldToViewportMatrix,
worldCorners and visibleWorldBounds. Use the matrix with Canvas.transform
when painting world-scaled/rotated content. Mapping an anchor alone intentionally
keeps a widget upright and at a constant screen size.
The world origin is stable across resize and RTL changes. The camera does not mirror coordinates or automatically center when mounted; text still inherits Flutter Directionality. No oversized canvas or repeated origin rebasing is used. The application builds/hit-tests its content and can use the viewport bounds to cull offscreen objects. The viewer knows nothing about nodes or edges.
Camera commands
camera.pan(const Offset(40, 20), animate: false);
camera.zoom(factor: .2, focalPoint: const Offset(200, 100));
camera.rotate(.25, focalPoint: const Offset(200, 100));
await camera.zoomToRegion(
const Rect.fromLTWH(-500, -300, 1000, 600), viewportSize,
);
await camera.centerOnRect(const Rect.fromLTWH(-200, -100, 80, 60));
await camera.reset();
zoom uses a relative increment: .2 requests 1.2 times the current scale.
Focal points and pan deltas are viewport-local pixels. World/content positions
are converted with the controller or viewport snapshot. Animated commands need
an attached viewer (or an explicit ticker provider); immediate commands work
headlessly. New direct manipulation cancels active camera animation. Interrupted
animation futures complete normally. Reset restores the initial camera values.
Navigation policies
Free navigation is the default. Finite widgets can use
ViewportBoundsBehavior. World canvases can use:
InteractionConfig(
boundsBehavior: WorldBoundsBehavior(
const Rect.fromLTWH(-1000, -800, 2000, 1600),
margin: 100,
),
)
For content-relative navigation, update the supplied rectangle when the application's content bounds change. Fit/center remain explicit commands rather than persistent constraints. With rotation, bounds constrain the transformed axis-aligned envelope, not exact polygon containment.
Input and ownership
ZoomConfig, InteractionConfig, and KeyboardConfig configure camera inputs.
Use ScrollMode to restrict pan axes and optional interaction behaviors for
snapping/custom constraints. Browser pinch signals are supported without a
physical Ctrl-key event. A controller belongs to one mounted viewer at a time;
replacing it detaches the old controller. Dispose controllers you create.
Child controls and editing gestures participate in Flutter's gesture arena.
The host application owns node dragging, selection and other domain gestures.
Example and verification
Run flutter run -d chrome from example for the camera playground. It exposes
free/bounded navigation, negative-coordinate content, rotation, RTL text,
fit/reveal and reset. Run flutter test at the package root for camera geometry,
gesture and lifecycle regression tests. Synthetic input tests do not replace
physical device testing across every operating system.