Skip to content

fix: add listener widget for interactive viewer - #6761

Open
7576457 wants to merge 1 commit into
flet-dev:mainfrom
7576457:fix/6755
Open

fix: add listener widget for interactive viewer#6761
7576457 wants to merge 1 commit into
flet-dev:mainfrom
7576457:fix/6755

Conversation

@7576457

@7576457 7576457 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

Test code

import flet as ft


@ft.component
def ImageComparison():
    return ft.Container(
        bgcolor='blue',
        expand=True,
        content=ft.Column(
            scroll=ft.ScrollMode.AUTO,
            expand=True,
            alignment=ft.MainAxisAlignment.CENTER,
            controls=[
                ft.Container(),
                ft.Container(
                    bgcolor=ft.Colors.random(),
                    content=ft.InteractiveViewer(
                        content=ft.Image(
                            src="https://picsum.photos/800/400",
                        ),
                    )
                ),
                ft.Container(
                    bgcolor=ft.Colors.random(),
                    content=ft.InteractiveViewer(
                        content=ft.Image(
                            src="https://picsum.photos/800/500",
                        ),
                    )
                ),
                ft.Container(
                    bgcolor=ft.Colors.random(),
                    content=ft.InteractiveViewer(
                        content=ft.Image(
                            src="https://picsum.photos/800/600",
                        )
                    )
                )
            ]
        )
    )


def main(page: ft.Page):
    page.title = "Image Comparison"
    page.render(ImageComparison)


ft.run(main)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist

  • I signed the CLA.
  • I have performed a self-review of my own code.
  • My code follows the style guidelines of this project.
  • I have commented my code, particularly in hard-to-understand areas.
  • My changes generate no new warnings.
  • New and existing tests pass locally with my changes.
  • I have made corresponding documentation changes, if applicable.
  • I have added changelog entries for user-facing changes, if applicable.
  • I have updated release guide pages and website/sidebars.yml for breaking changes, removals, and deprecations, if applicable.

Video

Screencast.From.2026-08-07.15-25-26.webm

Summary by Sourcery

Bug Fixes:

  • Prevent unwanted scaling or panning in InteractiveViewer by routing pointer signal events through a listener that respects control flags for pan and scale.

@7576457
7576457 marked this pull request as ready for review August 7, 2026 12:36

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@FeodorFitsner

Copy link
Copy Markdown
Contributor

Reviewed - the approach is correct and I verified the mechanism works. A few things before merge.

Why the bug happens (for the record)

Flutter's InteractiveViewer handles pointer signals directly in its own Listener callback and never registers with GestureBinding.instance.pointerSignalResolver (_receivedPointerSignal, widgets/interactive_viewer.dart in 3.44.8 - there is no pointerSignalResolver reference anywhere in that file). Scrollable does register (widgets/scrollable.dart:962). Since nothing claimed the event, the ancestor Scrollable wins by default, so the wheel zooms and scrolls. This PR claims the event with a no-op callback so Scrollable's later register() is discarded (first registration wins).

I confirmed both halves with a throwaway widget test - a ListView at offset 400 with the viewer in it, one mouse wheel tick over the viewer:

scroll offset scale
without the wrapper 400 -> 350 (the bug) 1.284
with the wrapper 400 (fixed) 1.284

Zoom keeps working precisely because InteractiveViewer never went through the resolver to begin with.

I also diffed _handlesPointerSignal against Flutter 3.44.8's _receivedPointerSignal branch by branch, and it mirrors it correctly: trackpad without trackpadScrollCausesScale -> pan branch gated on pan_enabled, scrollDelta.dy == 0 ignored, otherwise scale gated on scale_enabled, PointerScaleEvent -> scale. Defaults match the ones passed in build. Scrollables nested inside the viewer are unaffected, since they hit-test deeper and register first.

Requested changes

  1. Add a comment explaining the trick. A callback that deliberately does nothing is the least self-evident code there is - without a sentence saying "Flutter's InteractiveViewer handles pointer signals without claiming them via the PointerSignalResolver, so an ancestor Scrollable scrolls too; register a no-op to claim the event first", the next reader will delete it as dead code. Every other method in this file carries a /// doc.

  2. CHANGELOG entry - this is user-facing behavior and the checklist item is unchecked.

  3. Minor: the three property reads are duplicated from build, so their defaults now have to be kept in sync by hand. Hoisting them into fields set in build removes the drift risk.

One design question

The predicate claims the event whenever the viewer would handle it, not whenever it actually did something. That creates two dead zones:

  • Trackpad on macOS: with the default constrained=True and a fitted image at scale 1.0, a two-finger scroll pans nothing (the matrix clamps at the boundary), but the event is now claimed, so the page does not scroll either. Before this PR the page at least scrolled. That is a regression for trackpad users on exactly the layout from the issue - an image inside a scrolling column.
  • Wheel at max/min scale: same shape. Zoom cannot go further and the page will not scroll.

There is a clean fix, and I verified the ordering it relies on: the wrapper Listener fires after InteractiveViewer has already applied its transform (in a maxScale: 4.0 viewer the wrapper observed scale 1.284, i.e. post-transform). So the handler can keep the previous _transformationController.value and only register when the matrix actually changed - the viewer consumes the event when it does something and hands it to the parent scrollable when it does not.

Whether you want that is a judgment call, since some viewers deliberately swallow at the zoom limit, but the trackpad case looks worth handling either way.

CI

examples/apps Integration Tests failed at exactly 1h0m11s with no *_actual.png artifacts, which is a job timeout rather than a screenshot diff, and zizmor is a workflow linter - neither can be affected by this diff. The branch is from Aug 7 though, so please rebase on main and re-run to get the checks green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants