diff --git a/CHANGELOG.md b/CHANGELOG.md index 762a4d9..0dd9fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +Feature: +- Add `suppressInternalGestures` flag to `CanvasKit`. When true, the widget + builds no internal pan/pinch `GestureDetector` and ignores pointer‑signal + (mouse‑wheel) zoom, regardless of `interactionMode` or `gestureOverlayBuilder`. + This makes "the embedding app owns all input" an explicit, self‑documenting + intent instead of requiring an empty `gestureOverlayBuilder: (_, _) => + SizedBox.shrink()` to flip the switch. Default `false` (no behavior change for + existing callers; the previous implicit `programmatic + gestureOverlayBuilder` + suppression still applies). + ## 0.6.1 - 2026-03-06 Fix: diff --git a/lib/src/canvas_kit.dart b/lib/src/canvas_kit.dart index 27a5d52..9239137 100644 --- a/lib/src/canvas_kit.dart +++ b/lib/src/canvas_kit.dart @@ -70,6 +70,11 @@ class CanvasKit extends StatefulWidget { final double boundsFitPadding; final ValueChanged? onRenderStats; + /// When true, all children stay in the widget tree regardless of viewport + /// visibility. The app controls which items are in [children]; canvas_kit + /// skips its internal visibility culling. Default is false (cull off-screen). + final bool keepChildrenMounted; + // Optional: if provided in programmatic mode, the package will not handle // background pan/wheel; instead, this overlay can implement all gestures. // The overlay receives current transform and controller. @@ -78,6 +83,23 @@ class CanvasKit extends StatefulWidget { final Widget Function(Matrix4 transform, CanvasKitController controller)? gestureOverlayBuilder; + /// When true, CanvasKit builds no internal pan/pinch [GestureDetector] and + /// ignores pointer‑signal (mouse‑wheel) zoom, regardless of [interactionMode] + /// or [gestureOverlayBuilder]. Use this when the embedding app owns all input + /// from its own gesture layer — e.g. a full‑screen layer stacked *above* + /// CanvasKit that hit‑tests in world space and drives the [controller] + /// directly. + /// + /// Prefer this explicit flag over the legacy idiom of passing an empty + /// `gestureOverlayBuilder: (_, _) => SizedBox.shrink()` purely to disable the + /// library's gestures. (Note the [gestureOverlayBuilder] result is composited + /// *below* the canvas children, so it cannot host a layer that must hit‑test + /// above items — such a layer belongs in a sibling stacked above CanvasKit, + /// which is exactly the case this flag serves.) + /// + /// Default is false (the library handles input per [interactionMode]). + final bool suppressInternalGestures; + const CanvasKit({ super.key, required this.children, @@ -97,6 +119,8 @@ class CanvasKit extends StatefulWidget { this.autoFitToBounds = true, this.boundsFitPadding = 40.0, this.onRenderStats, + this.keepChildrenMounted = false, + this.suppressInternalGestures = false, }); @override @@ -470,14 +494,18 @@ class _CanvasKitState extends State { final transform = _controller!._transform; final scale = _controller!.scale; + // Input is handled internally unless the app explicitly opts out via + // [suppressInternalGestures], or (the legacy idiom) supplies a gesture + // overlay in programmatic mode. + final suppressInternal = widget.suppressInternalGestures || + (widget.interactionMode == InteractionMode.programmatic && + widget.gestureOverlayBuilder != null); + return Listener( behavior: HitTestBehavior.translucent, onPointerSignal: (event) { if (!widget.enableWheelZoom || event is! PointerScrollEvent) return; - if (widget.interactionMode == InteractionMode.programmatic && - widget.gestureOverlayBuilder != null) { - return; - } + if (suppressInternal) return; final double scaleDelta = event.scrollDelta.dy > 0 ? 0.9 : 1.1; final Offset screenPos = event.localPosition; final Offset worldBefore = _controller!.screenToWorld(screenPos); @@ -504,8 +532,7 @@ class _CanvasKitState extends State { child: widget.gestureOverlayBuilder!(transform, _controller!)), - if (!(widget.interactionMode == InteractionMode.programmatic && - widget.gestureOverlayBuilder != null)) + if (!suppressInternal) Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, @@ -568,6 +595,7 @@ class _CanvasKitState extends State { viewportSize: viewportSize, controller: _controller!, onRenderStats: widget.onRenderStats, + keepChildrenMounted: widget.keepChildrenMounted, children: widget.children, ), ), @@ -695,6 +723,7 @@ class SimpleCanvas extends StatelessWidget { final Size? viewportSize; final CanvasKitController? controller; final ValueChanged? onRenderStats; + final bool keepChildrenMounted; const SimpleCanvas({ super.key, @@ -704,6 +733,7 @@ class SimpleCanvas extends StatelessWidget { this.viewportSize, this.controller, this.onRenderStats, + this.keepChildrenMounted = false, }); @override @@ -812,9 +842,10 @@ class SimpleCanvas extends StatelessWidget { int visibleViewportCount = 0; for (final item in children) { - final visible = isVisible(item); - - if (!visible) continue; + if (!keepChildrenMounted) { + final visible = isVisible(item); + if (!visible) continue; + } if (item.anchor == CanvasAnchor.world) { Widget visual = item.child; diff --git a/test/suppress_internal_gestures_test.dart b/test/suppress_internal_gestures_test.dart new file mode 100644 index 0000000..0d6f5ea --- /dev/null +++ b/test/suppress_internal_gestures_test.dart @@ -0,0 +1,90 @@ +import 'package:canvas_kit/canvas_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Contract for the `suppressInternalGestures` flag. +/// +/// canvas_kit can either own pan/zoom itself (interactive mode) or hand input +/// to the embedding app. Before this flag, the only way to suppress the +/// library's own [GestureDetector] was to pass a non-null [gestureOverlayBuilder] +/// in programmatic mode — an implicit coupling that forced callers who wanted +/// to own input from a *sibling* layer (stacked above CanvasKit) to pass an +/// empty `(_, _) => SizedBox.shrink()` builder purely to flip the switch. +/// +/// `suppressInternalGestures: true` makes that intent explicit: the library +/// builds no internal pan/pinch [GestureDetector] and ignores pointer-signal +/// (wheel) zoom, regardless of [interactionMode] or [gestureOverlayBuilder]. +void main() { + Future pumpCanvas( + WidgetTester tester, { + required CanvasKitController controller, + required bool suppress, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 400, + child: CanvasKit( + controller: controller, + suppressInternalGestures: suppress, + children: const [], + ), + ), + ), + ), + ); + } + + testWidgets( + 'control: default CanvasKit pans on drag (proves the harness drives the gesture)', + (tester) async { + final controller = CanvasKitController(); + addTearDown(controller.dispose); + + await pumpCanvas(tester, controller: controller, suppress: false); + final before = controller.transform.getTranslation().x; + + await tester.drag(find.byType(CanvasKit), const Offset(120, 0)); + await tester.pumpAndSettle(); + + final after = controller.transform.getTranslation().x; + expect( + (after - before).abs(), + greaterThan(1.0), + reason: 'interactive CanvasKit must move the camera on drag, otherwise ' + 'the suppression assertion below would be vacuous', + ); + }, + ); + + testWidgets( + 'suppressInternalGestures: true disables the library\'s own pan gesture', + (tester) async { + final controller = CanvasKitController(); + addTearDown(controller.dispose); + + await pumpCanvas(tester, controller: controller, suppress: true); + final before = controller.transform.getTranslation().x; + + // The drag is *expected* to miss: with no internal GestureDetector there + // is nothing in CanvasKit's subtree to claim the pointer. That is the + // point of the flag, so the "hit test missed" warning is not a failure. + await tester.drag( + find.byType(CanvasKit), + const Offset(120, 0), + warnIfMissed: false, + ); + await tester.pumpAndSettle(); + + final after = controller.transform.getTranslation().x; + expect( + (after - before).abs(), + lessThan(0.001), + reason: 'with internal gestures suppressed the library must not move ' + 'the camera — the embedding app owns all input', + ); + }, + ); +}