diff --git a/CHANGELOG.md b/CHANGELOG.md index 1016528617..baaf30cdff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ * Fix integration tests failing to start when the host Python environment carries IDE configuration: `flutter test` exited with code 79 and "No tests were found" while the `flet_app` fixture failed during setup. `FletTestApp` launched the Flutter test process with the host environment inherited wholesale, and the interpreter embedded in the app under test reads `PYTHONPATH`/`PYTHONHOME` at initialization - so the debugger and `sitecustomize` paths PyCharm injects landed on the packaged app's `sys.path` and killed it before it could connect to `RemoteTester`. Since the failure happened inside the app rather than in the test process, it surfaced only as a Flutter exit code, which made it look like the tests themselves were missing. The Flutter subprocess now gets an explicit environment with `PYTHONPATH`, `PYTHONHOME` and `PYTHONEXECUTABLE` removed and `PYTHONNOUSERSITE=1` set - user site-packages is opt-out, so a host `~/.local/lib/pythonX.Y/site-packages` matching the embedded interpreter's version leaks in the same way. `PATH`, and every `FLET_*` and `SERIOUS_PYTHON_*` variable the native build phase needs, are untouched ([#6747](https://github.com/flet-dev/flet/pull/6747)) by @PythBuster. * Fix ink ripples and hover highlights not covering the whole `Container` when both `ink=True` and `animate` are set, and its `padding` being applied twice. In that combination `padding` and `alignment` were passed to the outer `AnimatedContainer` *and* to the inner `Container` that wraps the content inside the `InkWell`, so a `padding=10` container was laid out with 20 on each side. The duplicated `alignment` was the more visible half: it made the `Material`/`InkWell` shrink-wrap to the content, so splashes and the hover overlay stopped short of the container's edges while `bgcolor` on the same container still filled it - the two disagreed on where the control ended. Both properties now live only on the inner container, which becomes an `AnimatedContainer` when `animate` is set so that padding and alignment changes still animate; this is what the non-animated ink path already did. Inked, animated containers with padding will render tighter than before - by the padding they declare, instead of double ([#6757](https://github.com/flet-dev/flet/pull/6757)) by @FeodorFitsner. * Fix `disabled=True` having almost no effect on `Radio` and `CupertinoRadio`: the radio could still be selected and kept its enabled colors; only the label grayed out. Broken since the migration to Flutter's native `RadioGroup` widget ([#5651](https://github.com/flet-dev/flet/pull/5651)), whose API needs an explicit `enabled: false` that Flet never passed. A disabled radio now ignores clicks and renders grayed out (`fill_color`'s `ControlState.DISABLED` value applies too), disabling a whole `RadioGroup` cascades to its radios, and the label grays out with the default text style as well ([#6159](https://github.com/flet-dev/flet/issues/6159), [#6769](https://github.com/flet-dev/flet/pull/6769)) by @ndonkoHenri. +* Fix a mouse wheel tick over an `InteractiveViewer` both zooming it *and* scrolling the enclosing scrollable, so zooming an image inside a scrolling `Column` moved the page out from under the pointer. Flutter's `InteractiveViewer` applies pointer signals directly from its own listener and never claims them through the `PointerSignalResolver`, unlike `Scrollable`, which does - with nothing claiming the event first, the ancestor scrollable handled it as well. The viewer now claims the signals it acts on, so the scrollable ignores them. Signals the viewer does not act on still reach the parent: with `scale_enabled=False`, and - unlike a bare Flutter `InteractiveViewer` - when the transform comes out unchanged because the zoom is already at `min_scale`/`max_scale` or a trackpad pan is clamped at the content boundary, which keeps a fitted, unzoomed image from becoming a region where a trackpad cannot scroll the page at all ([#6755](https://github.com/flet-dev/flet/issues/6755), [#6761](https://github.com/flet-dev/flet/pull/6761)) by @7576457. ### Documentation diff --git a/packages/flet/lib/src/controls/interactive_viewer.dart b/packages/flet/lib/src/controls/interactive_viewer.dart index 916beafe2a..732796177f 100644 --- a/packages/flet/lib/src/controls/interactive_viewer.dart +++ b/packages/flet/lib/src/controls/interactive_viewer.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3; @@ -45,6 +46,17 @@ class _InteractiveViewerControlState extends State int _interactionUpdateTimestamp = DateTime.now().millisecondsSinceEpoch; final double _currentRotation = 0.0; + /// Gesture settings the viewer was last built with, mirrored here so the + /// pointer signal handlers can tell what the viewer does with an event. + bool _panEnabled = true; + bool _scaleEnabled = true; + bool _trackpadScrollCausesScale = false; + + /// The transform as it was just before the pointer signal identified by + /// [_signalTransformKey] reached [InteractiveViewer]. + Object? _signalTransformKey; + Matrix4? _transformBeforeSignal; + @override void initState() { super.initState(); @@ -125,13 +137,19 @@ class _InteractiveViewerControlState extends State "InteractiveViewer.content must be provided and visible"); } - var interactiveViewer = InteractiveViewer( + // Also read by the pointer signal handlers below, which must agree with + // what the viewer was actually built with. + _panEnabled = widget.control.getBool("pan_enabled", true)!; + _scaleEnabled = widget.control.getBool("scale_enabled", true)!; + _trackpadScrollCausesScale = + widget.control.getBool("trackpad_scroll_causes_scale", false)!; + + Widget interactiveViewer = InteractiveViewer( key: _viewerKey, transformationController: _transformationController, - panEnabled: widget.control.getBool("pan_enabled", true)!, - scaleEnabled: widget.control.getBool("scale_enabled", true)!, - trackpadScrollCausesScale: - widget.control.getBool("trackpad_scroll_causes_scale", false)!, + panEnabled: _panEnabled, + scaleEnabled: _scaleEnabled, + trackpadScrollCausesScale: _trackpadScrollCausesScale, constrained: widget.control.getBool("constrained", true)!, maxScale: widget.control.getDouble("max_scale", 2.5)!, minScale: widget.control.getDouble("min_scale", 0.8)!, @@ -166,12 +184,74 @@ class _InteractiveViewerControlState extends State } } : null, - child: KeyedSubtree(key: _childKey, child: content), + // Sits below InteractiveViewer's own Listener, so it is dispatched + // first and can record the transform before the viewer changes it. + child: Listener( + onPointerSignal: _recordTransformBeforePointerSignal, + child: KeyedSubtree(key: _childKey, child: content), + ), + ); + + // Flutter's InteractiveViewer applies pointer signals (mouse wheel, + // trackpad scroll) straight from its own Listener and never claims them + // through the PointerSignalResolver, while Scrollable does claim them. + // With no one claiming first, an enclosing scrollable handled the same + // event, so a wheel tick over the viewer zoomed *and* scrolled the page. + // Registering a no-op first wins the resolver - the first registration + // for an event wins - which leaves the ancestor scrollable out of it. + interactiveViewer = Listener( + onPointerSignal: _claimHandledPointerSignal, + child: interactiveViewer, ); return LayoutControl(control: widget.control, child: interactiveViewer); } + /// Snapshots the transform before [InteractiveViewer] reacts to [event], + /// keyed by the event so [_claimHandledPointerSignal] only trusts a + /// snapshot taken for the very signal it is resolving. + void _recordTransformBeforePointerSignal(PointerSignalEvent event) { + _signalTransformKey = event.original ?? event; + _transformBeforeSignal = _transformationController.value.clone(); + } + + /// Claims [event] so that scrollables above this control ignore it. + void _claimHandledPointerSignal(PointerSignalEvent event) { + if (!_handlesPointerSignal(event)) return; + + // Runs after the viewer has already transformed itself. When the + // transform came out unchanged - a zoom that is already at min/max + // scale, or a trackpad pan clamped at the boundary - the viewer did + // nothing with the event, so let it through to the enclosing scrollable + // instead of swallowing it into a dead zone. The snapshot is missing + // when the pointer is over a part of the viewport the content does not + // cover; the event is then claimed, as it is the viewer's to handle. + if (identical(_signalTransformKey, event.original ?? event) && + _transformBeforeSignal == _transformationController.value) { + return; + } + + GestureBinding.instance.pointerSignalResolver.register(event, (_) {}); + } + + /// Whether [InteractiveViewer] acts on [event], mirroring the branches of + /// its private `_receivedPointerSignal`. + bool _handlesPointerSignal(PointerSignalEvent event) { + if (event is PointerScaleEvent) { + return _scaleEnabled; + } + if (event is! PointerScrollEvent) { + return false; + } + // A trackpad scroll pans instead of scaling, unless configured otherwise. + if (event.kind == PointerDeviceKind.trackpad && + !_trackpadScrollCausesScale) { + return _panEnabled; + } + // Horizontal-only wheel scroll is ignored by the viewer. + return event.scrollDelta.dy != 0.0 && _scaleEnabled; + } + /// Returns a copy of [matrix] scaled by [scale] while honoring the viewer's /// min/max scale settings and ensuring the content still covers the viewport. Matrix4 _matrixScale(Matrix4 matrix, double scale) {