Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
49 changes: 40 additions & 9 deletions lib/src/canvas_kit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ class CanvasKit extends StatefulWidget {
final double boundsFitPadding;
final ValueChanged<CanvasKitRenderStats>? 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.
Expand All @@ -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,
Expand All @@ -97,6 +119,8 @@ class CanvasKit extends StatefulWidget {
this.autoFitToBounds = true,
this.boundsFitPadding = 40.0,
this.onRenderStats,
this.keepChildrenMounted = false,
this.suppressInternalGestures = false,
});

@override
Expand Down Expand Up @@ -470,14 +494,18 @@ class _CanvasKitState extends State<CanvasKit> {
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);
Expand All @@ -504,8 +532,7 @@ class _CanvasKitState extends State<CanvasKit> {
child:
widget.gestureOverlayBuilder!(transform, _controller!)),

if (!(widget.interactionMode == InteractionMode.programmatic &&
widget.gestureOverlayBuilder != null))
if (!suppressInternal)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
Expand Down Expand Up @@ -568,6 +595,7 @@ class _CanvasKitState extends State<CanvasKit> {
viewportSize: viewportSize,
controller: _controller!,
onRenderStats: widget.onRenderStats,
keepChildrenMounted: widget.keepChildrenMounted,
children: widget.children,
),
),
Expand Down Expand Up @@ -695,6 +723,7 @@ class SimpleCanvas extends StatelessWidget {
final Size? viewportSize;
final CanvasKitController? controller;
final ValueChanged<CanvasKitRenderStats>? onRenderStats;
final bool keepChildrenMounted;

const SimpleCanvas({
super.key,
Expand All @@ -704,6 +733,7 @@ class SimpleCanvas extends StatelessWidget {
this.viewportSize,
this.controller,
this.onRenderStats,
this.keepChildrenMounted = false,
});

@override
Expand Down Expand Up @@ -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;
Expand Down
90 changes: 90 additions & 0 deletions test/suppress_internal_gestures_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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',
);
},
);
}