Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

InteractiveViewer should support mouse scrolling #64210

Closed
creativecreatorormaybenot opened this issue Aug 20, 2020 · 29 comments
Closed

InteractiveViewer should support mouse scrolling #64210

creativecreatorormaybenot opened this issue Aug 20, 2020 · 29 comments
Labels
c: new feature Nothing broken; request for a new capability c: proposal A detailed proposal for a change to Flutter f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels. P2 Important issues not at the top of the work list

Comments

@creativecreatorormaybenot
Copy link
Contributor

Use case

We use @justinmc's InteractiveViewer widget for vertical lists that can have an arbitrary width (the items can have arbitrary widths).
→ you can obviously apply the same to the reverse dimensions :)

This is very nice for allowing this use case. However, what is a bummer that the mouse scrolling characteristic is lost due to this widget.
Users find this to be really unintuitive on web.

Proposal

The InteractiveViewer should have something like a scrollingDirection attribute, which determines the Axis along which a mouse scroll will scroll the InteractiveViewer.

In my example, this would allow to use the mouse scroll wheel (or trackpad scrolling) to scroll vertically while scrolling horizontally still needs a drag.

@HansMuller HansMuller added f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels. c: new feature Nothing broken; request for a new capability labels Aug 20, 2020
@justinmc
Copy link
Contributor

That sounds reasonable, thanks for opening this issue. Maybe if scaling is disabled via scaleEnabled we could make scroll events cause panning by default? Or maybe that's too simplistic and we do need a parameter, because people might not want the behavior to change if they temporarily disable scale, for example.

We should also think about mice that have directional scrolling, like Apple's mice.

@creativecreatorormaybenot
Copy link
Contributor Author

@justinmc That sounds very good! 👍🏽

Actually, just yesterday we also discovered the case of directional scrolling. Any trackpad usually allows scrolling in all directions and that would be a great fit :)
An example for this would be Lucidchart (in case you know it). It allows to pan using trackpads by default.

Regarding your point of automatic panning on scroll: I think that this is very reasonable because it seems like it would be the natural choice. If we create an interactive viewer, we probably expect it to be interactive.
In all of the use cases that I had, I only used it for panning actually and disabled scaling because of that.

@justinmc
Copy link
Contributor

Ah thanks for the Lucidchart example. It looks like they do panning with bidirectional scrolling by default, and holding down the option key + scrolling does zoom. Come to think of it, I think this is how photo editors like Gimp and Photoshop work on desktop too.

I think we can do a panByScrolling boolean to enable this behavior, or even a controlScheme enum with these two options.

@Schwusch
Copy link

I just copied the InteractiveViewer class and replaced the _receivedPointerSignal(PointerSignalEvent) function with this

void _receivedPointerSignal(PointerSignalEvent event) {
    if (event is PointerScrollEvent) {
      if (_gestureIsSupported(_GestureType.scale)) {
        final childRenderBox =
        _childKey.currentContext.findRenderObject() as RenderBox;
        final childSize = childRenderBox.size;
        final scaleChange = 1.0 - event.scrollDelta.dy / childSize.height;
        if (scaleChange == 0.0) {
          return;
        }
        final focalPointScene = _transformationController.toScene(
          event.localPosition,
        );
        _transformationController.value = _matrixScale(
          _transformationController.value,
          scaleChange,
        );

        // After scaling, translate such that the event's position is at the
        // same scene point before and after the scale.
        final focalPointSceneScaled = _transformationController.toScene(
          event.localPosition,
        );
        _transformationController.value = _matrixTranslate(
          _transformationController.value,
          focalPointSceneScaled - focalPointScene,
        );
      } else if (_gestureIsSupported(_GestureType.pan)) {
        final childRenderBox =
        _childKey.currentContext.findRenderObject() as RenderBox;
        final childSize = childRenderBox.size;
        final yScroll = -event.scrollDelta.dy;
        final xScroll = -event.scrollDelta.dx;
        _transformationController.value = _matrixTranslate(
          _transformationController.value,
          Offset(xScroll, yScroll),
        );
      }
    }
  }

Scaleing takes precedence, but if that's disabled the widget works like a charm with mousepads or scrollwheels.
Now I am just missing scrollbars, in both directions.

@pedromassangocode pedromassangocode added passed first triage c: proposal A detailed proposal for a change to Flutter labels Oct 27, 2020
@hacker1024
Copy link
Contributor

hacker1024 commented Jan 30, 2021

It looks like a rotation feature is in the works (#57698); it'd be great if a rotation touch gesture on macOS and iPadOS could trigger that as well, as is done in native apps like macOS's Preview.

On another note, for those on the dev channel, click to expand @Schwusch's solution ported to Flutter 1.26.0-17.1.pre.
// Handle mousewheel scroll events.
void _receivedPointerSignal(PointerSignalEvent event) {
  if (event is PointerScrollEvent) {
    widget.onInteractionStart?.call(
      ScaleStartDetails(
        focalPoint: event.position,
        localFocalPoint: event.localPosition,
      ),
    );
    if (_gestureIsSupported(_GestureType.scale)) {
      // Ignore left and right scroll.
      if (event.scrollDelta.dy == 0.0) {
        return;
      }

      // In the Flutter engine, the mousewheel scrollDelta is hardcoded to 20 per scroll, while a trackpad scroll can be any amount.
      // The calculation for scaleChange here was arbitrarily chosen to feel natural for both trackpads and mousewheels on all platforms.
      final double scaleChange = math.exp(-event.scrollDelta.dy / 200);
      final Offset focalPointScene = _transformationController!.toScene(
        event.localPosition,
      );

      _transformationController!.value = _matrixScale(
        _transformationController!.value,
        scaleChange,
      );

      // After scaling, translate such that the event's position is at the
      // same scene point before and after the scale.
      final Offset focalPointSceneScaled = _transformationController!.toScene(
        event.localPosition,
      );
      _transformationController!.value = _matrixTranslate(
        _transformationController!.value,
        focalPointSceneScaled - focalPointScene,
      );

      widget.onInteractionUpdate?.call(ScaleUpdateDetails(
        focalPoint: event.position,
        localFocalPoint: event.localPosition,
        rotation: 0.0,
        scale: scaleChange,
        horizontalScale: 1.0,
        verticalScale: 1.0,
      ));
      widget.onInteractionEnd?.call(ScaleEndDetails());
    } else {
      widget.onInteractionEnd?.call(ScaleEndDetails());

      if (_gestureIsSupported(_GestureType.pan)) {
        _transformationController!.value = _matrixTranslate(
          _transformationController!.value,
          Offset(-event.scrollDelta.dx, -event.scrollDelta.dy),
        );
      }
    }
  }
}

@zhou67832033
Copy link

zhou67832033 commented Feb 2, 2021

@justinmc InteractiveViewer should support scrollbars, in both directions. Just like figma. Thanks!

Example:
https://www.figma.com/file/B0NMrrVSGh64uFmXqA1Ly8/Pegasus-Design-System-(Community)?node-id=99%3A921

@justinmc
Copy link
Contributor

justinmc commented Feb 2, 2021

@zhou67832033 Thanks, that's a great example of 2d mouse scrolling with scroll bars!

@zhou67832033
Copy link

@zhou67832033 Thanks, that's a great example of 2d mouse scrolling with scroll bars!

Are there currently plans to support Scrollbar feature?

@zhou67832033
Copy link

scrollbars

I just copied the InteractiveViewer class and replaced the _receivedPointerSignal(PointerSignalEvent) function with this

void _receivedPointerSignal(PointerSignalEvent event) {
    if (event is PointerScrollEvent) {
      if (_gestureIsSupported(_GestureType.scale)) {
        final childRenderBox =
        _childKey.currentContext.findRenderObject() as RenderBox;
        final childSize = childRenderBox.size;
        final scaleChange = 1.0 - event.scrollDelta.dy / childSize.height;
        if (scaleChange == 0.0) {
          return;
        }
        final focalPointScene = _transformationController.toScene(
          event.localPosition,
        );
        _transformationController.value = _matrixScale(
          _transformationController.value,
          scaleChange,
        );

        // After scaling, translate such that the event's position is at the
        // same scene point before and after the scale.
        final focalPointSceneScaled = _transformationController.toScene(
          event.localPosition,
        );
        _transformationController.value = _matrixTranslate(
          _transformationController.value,
          focalPointSceneScaled - focalPointScene,
        );
      } else if (_gestureIsSupported(_GestureType.pan)) {
        final childRenderBox =
        _childKey.currentContext.findRenderObject() as RenderBox;
        final childSize = childRenderBox.size;
        final yScroll = -event.scrollDelta.dy;
        final xScroll = -event.scrollDelta.dx;
        _transformationController.value = _matrixTranslate(
          _transformationController.value,
          Offset(xScroll, yScroll),
        );
      }
    }
  }

Scaleing takes precedence, but if that's disabled the widget works like a charm with mousepads or scrollwheels.
Now I am just missing scrollbars, in both directions.

Have you implemented scrollbars, can you share it?

@justinmc
Copy link
Contributor

justinmc commented Feb 3, 2021

No one is currently working on scrollbars for InteractiveViewer that I'm aware of, but it seems like a good idea.

@Schwusch
Copy link

Schwusch commented Feb 5, 2021

Have you implemented scrollbars, can you share it?

@zhou67832033 I have not, sorry

@marcosagni98
Copy link

vertical scroll should work using the scrollWheel and then the zoom using control + scroll

@creativecreatorormaybenot
Copy link
Contributor Author

creativecreatorormaybenot commented May 4, 2021

Yes precisely, this is rather straight-forward to implement. You can see an example I built in this demo.

For devices without a trackpad, you can use shift + scroll for horizontal panning. For devices with trackpads, you simply use the 2D scroll.

@xuanswe
Copy link

xuanswe commented May 13, 2021

It would be nice if scrollbar is supported by InteractiveViewer. Currently, I have a some troubles when using nested scrollbars for the same purpose.
See: https://stackoverflow.com/q/67461431/8228301

@justinmc
Copy link
Contributor

This works for panning by scrolling: master...justinmc:iv-scroll-pan

However, what are all of the use cases that we want to support here? Some people have mentioned that scrolling should only pan in the vertical direction and shift+scroll should pan horizontally, others seem to want slightly different behavior. Can we support everyone's use cases using an enum like in that branch? Or do we need something more powerful?

@justinmc
Copy link
Contributor

Related: Another issue calls for pinch-to-zoom with the trackpad #63084

@creativecreatorormaybenot
Copy link
Contributor Author

@justinmc having written support for this in Flutter myself, looking at other Flutter web apps like Rive (+ looking at their sources), and working with many apps supporting this (Miro, Lucidchart, etc.), this is the default behavior I have observed:

  • Free panning using trackpad should be supported if the trackpad allows for it (example is MacBook).
  • Locked panning if you only have mouse wheel (or some other kind of scroll lock): regular scrolls vertically, with shift it scrolls horizontally.
  • Shift should also lock to horizontal panning on trackpad, so no need to do any special handling (it just works perfectly implicitly).
  • Pinching on trackpad should zoom.
  • Holding ctrl (command on macOS) & scrolling should zoom instead of pan.
  • Using control / command and the plus & minus buttons should zoom in & out.

@justinmc tbh, you can probably just go to editor.rive.app and copy their behavior because it is written in Flutter Web and does exactly what all other major apps do.

The behavior is not too complicated to implement perfectly. There is one blocker: Rive and I both had to override some default DOM behaviors (see my answers on SO, i.e. this one and this one). Also, you might want to add some "polyfill"-esque code to make sure the behavior is consistent across all browsers. You can check the Rive source for that.

Overall, I think this is the expected behavior everywhere looking at all major apps and it is fairly trivial to get perfectly working with a bit of HTML event handling. I can share my implementation if that helps :)

@xvrh
Copy link
Contributor

xvrh commented Nov 20, 2021

@justinmc Thank you for the InteractiveViewerScrollControls.scrollPans code. I really like the idea.
I was able to easily build the "press CMD/CTRL to zoom" interaction:

class __FlowGraphState extends State<_FlowGraph> {
  bool _isZoomKeyPressed = false;

  @override
  void initState() {
    super.initState();
    RawKeyboard.instance.addListener(_onKeyPressed);
  }

  void _onKeyPressed(RawKeyEvent event) {
    var zoomKeyPressed = event.isControlPressed || event.isMetaPressed;
    if (_isZoomKeyPressed != zoomKeyPressed) {
      setState(() {
        _isZoomKeyPressed = zoomKeyPressed;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return InteractiveViewer(
      scrollControls: _isZoomKeyPressed
          ? InteractiveViewerScrollControls.scrollScales
          : InteractiveViewerScrollControls.scrollPans,
      maxScale: 1.5,
      minScale: 0.1,
      constrained: false,
      boundaryMargin: EdgeInsets.all(5000),
      child: child,
    );
  }

  @override
  void dispose() {
    RawKeyboard.instance.removeListener(_onKeyPressed);
    super.dispose();
  }
}

The only thing I would change in the scrollPans code is to adapt the panning to feel the same whatever the scale is.
So divide the translation by the currentScale:

var currentScale = _transformationController!.value.getMaxScaleOnAxis();
final Offset translation = Offset(-event.scrollDelta.dx, -event.scrollDelta.dy) / currentScale;

@rodydavis
Copy link
Contributor

Currently the PointerScrollEvent does not send the implicit "ctrlKey' == true when the gesture is zooming vs panning. You can keep track of the control key separately with the keyboard listener.

 if (event is PointerScrollEvent) {
    // TODO: Scale and Pan at the same time
    if (_controlPressed) {
      double zoomDelta = (-event.scrollDelta.dy / 300);
      final scale = zoomDelta;
      final origin = controller.mousePosition;
      print("scale: ${scale} at origin: ${origin}");
    } else {
      final panDelta = -event.scrollDelta;
      print("pan: ${panDelta}");
    }
  }

You can capture this information correctly with dart:html and the control key is implicitly true when its a zoom gesture on the trackpad:

html.document.body!.addEventListener('wheel', (e) {
      e.preventDefault();
      final event = e as html.WheelEvent;
      final origin = event.offset;
      if (e.ctrlKey == true) {
        double scale = 1;
        if (event.deltaY < 0) {
          scale = 0.1;
        } else {
          scale = -0.1;
        }
        print("scale: ${scale} at origin: ${origin}");
      } else {
        final panDelta = Offset(-event.deltaX.toDouble(), -event.deltaY.toDouble());
        print("pan: ${panDelta}");
      }
    }, false);

Wheel Event:

https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event

You can pan and zoom with the same event with this demo:

https://rodydavis.github.io/html-canvas-utilities/

@Piinks Piinks self-assigned this Jun 13, 2022
@Piinks Piinks added the P1 High-priority issues at the top of the work list label Jun 13, 2022
@Piinks
Copy link
Contributor

Piinks commented Jun 13, 2022

I recently looked into InteractiveViewer and making it a 2D scrollable under the hood, and unfortunately, that is not something we are likely to do. Retro-fitting this for scrolling would likely require a bunch of breaking changes to this widget.
This is because InteractiveViewer is not actually a scrolling widget.
Instead I have been working on a separate 2D scrollable that will be very similar to this. In contrast, it will work with all of the existing scrolling constructs, like scroll notifications, scrollbars, etc.

Regarding this issue though, there are a lot of comments with varying feature requests. I'd like to resolve these. Since there are several, we will likely need to split them out individual issues so we can get them assigned and addressed.

From what I gather above:

  1. support for panning the interactive viewer with pointer scrolling instead of scaling
  1. trackpad pinch to scale
  1. ❓ platform specific behaviors
  2. ❓ device specific behaviors (trackpad v mouse, etc)

Can anyone confirm some of this for us? With a clear picture of what is needed, we can set about having it done. :) Thanks!

@Piinks Piinks added the waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds label Jun 13, 2022
@xvrh
Copy link
Contributor

xvrh commented Jun 17, 2022

@Piinks

  1. support for panning the interactive viewer with pointer scrolling instead of scaling

The code from @justinmc master...justinmc:iv-scroll-pan posted in #64210 (comment) is a good start.
That would be great to have it integrated.

  1. trackpad pinch to scale

I confirm that with the latest version on master Flutter 3.1.0-0.0.pre.1292, pinch to scale is working.

@Piinks
Copy link
Contributor

Piinks commented Jun 17, 2022

Great! Thank for the feedback. I have updated the list above.

@creativecreatorormaybenot
Copy link
Contributor Author

I recently looked into InteractiveViewer and making it a 2D scrollable under the hood, and unfortunately, that is not something we are likely to do. Retro-fitting this for scrolling would likely require a bunch of breaking changes to this widget.

I would like to point out that adding mouse scrolling to InteractiveViewer does not require "Retro-fitting this for scrolling", which also means that it would not require "a bunch of breaking changes to this widget". I do understand that making it a scrollable would require that, but do we need to make it a scrollable?

@Piinks Is the new widget you are planning to build meant to replace InteractiveViewer? Otherwise, I think it might be worth to challenge the requirement of making it integrate with scroll notifications et al.

@github-actions github-actions bot removed the waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds label Jun 18, 2022
@Hixie
Copy link
Contributor

Hixie commented Jun 21, 2022

@creativecreatorormaybenot Mouse scrolling on some platforms (macOS notably) requires scrolling physics, and implementing that requires either the whole scrolling infrastructure or duplicating that whole logic, neither of which is reasonable to retrofit into a widget that wasn't built for it. Our goal is to have widgets be coherent and implement the same APIs so that they are easier to fit together. How exactly this will turn out for InteractiveViewer is yet to be determined. I recommend letting @Piinks work through the design and see what they come up with. In the meantime if you would be satisfied with a variant of InteractiveViewer that implements just part of the problem (e.g. non-physics-aware mouse scrolling) then you are welcome to fork the widget and upload a package to pub.

@davsl
Copy link

davsl commented Jul 20, 2022

I did the impossible... I found a way to both support mouse scrolling and showing both scrollbars using InteractiveViewer class. I made this package: flutterx_table
This package was originally created as a more customizable alternative to DataTable but I also introduced a very useful component:
ScrollableViewScrollbar.both( controllerX: _controllerX, controllerY: _controllerY, child: ScrollableView( controllerX: _controllerX, controllerY: _controllerY, child: <whatever>));
It works perfectly with Apple Trackpad and allows to scroll smoothly in every direction
Schermata 2022-07-20 alle 20 11 43

@Piinks Piinks added P2 Important issues not at the top of the work list and removed P1 High-priority issues at the top of the work list labels Oct 28, 2022
@Piinks Piinks removed their assignment Dec 2, 2022
@Piinks
Copy link
Contributor

Piinks commented Dec 2, 2022

I am removing my assignment because I am focused on a separate 2D scrolling widget (See https://github.com/orgs/flutter/projects/32), and @moffatman has been working on this specifically in the linked PRs above. :)

@krishnagandrath
Copy link

krishnagandrath commented Jan 6, 2023

It should also have an

option to disable mouse wheel events.

In my case I need interactive viewer for mobile devices but ** don't want to update scale for mouse wheel events**. Is there any way to disable it or do we need to write custom code ourselves?

@justinmc
Copy link
Contributor

justinmc commented Jan 6, 2023

Now that #112171 and related PRs are merged, scrolling on a mouse or trackpad causes a pan, and pinching on a trackpad causes a scale. There are several other suggestions in this issue (like the previous comment), but I'm going to consider this issue complete.

For any other related features/bugs, please create a new issue and tag me.

For the option to allow scrolling to scale as before, see #114280.

@github-actions
Copy link

github-actions bot commented Mar 4, 2023

This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Mar 4, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
c: new feature Nothing broken; request for a new capability c: proposal A detailed proposal for a change to Flutter f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels. P2 Important issues not at the top of the work list
Projects
None yet
Development

No branches or pull requests