5

I am trying to make a Widget that acts exactly like the Google Calendar Week View. That means

  • Pinch to Zoom
  • Scroll Vertically

Here is an example.

And here is the GitHub repository of the example.

For the purpose of simplicity I want to focus on just vertical scrolling being possible, I can add the rest myself.

The problem is that the PinchToZoom thing is very unreliable and often even though I am pinch-zooming, the lists begin to scroll. Why is this happening? I did some research and found this article.

It basically describes a simplified version of my problem, that is two GestureDetectors competing. The solution is a RawGestureDetector. I wrote my own:

class PinchToZoomGestureRecognizer extends OneSequenceGestureRecognizer {
  final void Function() onScaleStart;
  final void Function() onScaleUpdate;
  final void Function() onScaleEnd;

  PinchToZoomGestureRecognizer({
    required this.onScaleStart,
    required this.onScaleUpdate,
    required this.onScaleEnd,
  });

  @override
  String get debugDescription => '$runtimeType';

  Map<int, Offset> pointerPositionMap = {};

  @override
  void addAllowedPointer(PointerEvent event) {
    startTrackingPointer(event.pointer);
    pointerPositionMap[event.pointer] = event.position;
    if (pointerPositionMap.length >= 2) {
      resolve(GestureDisposition.accepted);
    }
  }
  @override
  void handleEvent(PointerEvent event) {
    if (event is PointerMoveEvent) {
      pointerPositionMap[event.pointer] = event.position;
      return;
    } else if (event is PointerDownEvent) {
      pointerPositionMap[event.pointer] = event.position;
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      stopTrackingPointer(event.pointer);
      pointerPositionMap.remove(event.pointer);
    }

    if (pointerPositionMap.length >= 2) {
      resolve(GestureDisposition.accepted);
    }
  }

  @override
  void didStopTrackingLastPointer(int pointer) {
    resolve(GestureDisposition.rejected);
  }
}

All this is supposed to do is if there are two pointers or more, declare Victory in the GestureArena. This works fine if both pointers are entering the arena at the same time. However, if they are not, and the first pointer is accepted by the ListView, for the second pointer my GestureDetector is not even added to the Arena:

I/flutter (13717): Gesture arena 8    ❙ ★ Opening new gesture arena.
I/flutter (13717): Gesture arena 8    ❙ Adding: PinchToZoomGestureRecognizer#f9b77
I/flutter (13717): Gesture arena 8    ❙ Adding: VerticalDragGestureRecognizer#07525(start behavior: start)
I/flutter (13717): Gesture arena 8    ❙ Closing with 2 members.
I/flutter (13717): Gesture arena 8    ❙ Accepting: VerticalDragGestureRecognizer#07525(start behavior: start)
I/flutter (13717): Gesture arena 8    ❙ Self-declared winner: VerticalDragGestureRecognizer#07525(start behavior: start)
D/EGL_emulation(13717): app_time_stats: avg=781.66ms min=5.34ms max=12373.45ms count=16
I/flutter (13717): Gesture arena 9    ❙ ★ Opening new gesture arena.
I/flutter (13717): Gesture arena 9    ❙ Adding: VerticalDragGestureRecognizer#07525(start behavior: start)
I/flutter (13717): Gesture arena 9    ❙ Accepting: VerticalDragGestureRecognizer#07525(start behavior: start)
I/flutter (13717): Gesture arena 9    ❙ Closing with 1 member.
I/flutter (13717): Gesture arena 9    ❙ Default winner: VerticalDragGestureRecognizer#07525(start behavior: start)

This means, if the user doesnt perfectly time their two fingers to pinch-zoom, the list will simply scroll.

Here is the Widget Code I used to test this:

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  debugPrintGestureArenaDiagnostics = true;
  runApp(const MaterialApp(home: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        child: RawGestureDetector(
          gestures: <Type, GestureRecognizerFactory>{
            PinchToZoomGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                PinchToZoomGestureRecognizer>(
                  () => PinchToZoomGestureRecognizer(
                onScaleStart: () {},
                onScaleUpdate: () {},
                onScaleEnd: () {},
              ),
                  (instance) {},
            ),
          },
          child: Container(
            width: MediaQuery.of(context).size.width,
            decoration: const BoxDecoration(
              color: Colors.blueGrey,
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: List.generate(
                100,
                (index) => Text("Item $index"),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Note that I didn't actually implement pinch-zooming because that is trivial, its just about getting that GestureDetector to recognize the Gesture in the first place.

If anyone can help me with either a completely different approach for pinch-zooming a ListView or knows how the RawGestureDetector works and why in this case it doesnt work, I would really appreciate it.

Any idea how to make a reliable pinch-to-zoom widget?

Lasslos05
  • 414
  • 2
  • 12
  • Take a look at this https://stackoverflow.com/a/60283485/6704033 – Er1 Oct 07 '22 at 08:57
  • Well this is for text, not as in my example for scrollable in both directions and it uses textScale which I cannot. So this is not applicable. – Lasslos05 Oct 07 '22 at 10:53

3 Answers3

1

For pinch-to-zoom functionality you can use InteractiveViewer widget that allows you to recognize the scale event, setup min/max scale factors and etc

Example of simple usage:

InteractiveViewer(
    minScale: 0.1,
    maxScale: 2.0,
    onInteractionStart: (details) {},
    onInteractionUpdate: (details) {},
    onInteractionEnd: (details) {},
    child: widget,
 )
powerman23rus
  • 1,297
  • 1
  • 9
  • 17
  • Sorry, this doesnt work for two reasons: 1. It breaks the list, while you are zoomed in, you cannot scroll all the way up or down and two I only want to zoom the items in the direction the list goes so in the direction they can expand, not in every direction. – Lasslos05 Oct 16 '22 at 16:28
1

EDIT: Upon usage of the old answer below, I ran into some different issues such as some failed assertions within Scrollable. My new approach is as follows:

  1. Copy and paste code from DragGestureRecognizer and rename to SinglePointerDragRecognizer. Change the add allowed pointer method to the following:
@override
  void addAllowedPointer(PointerDownEvent event) {
    super.addAllowedPointer(event);
    _velocityTrackers[event.pointer] = velocityTrackerBuilder(event);

    // ADD THIS

    if (_velocityTrackers.length > 1) {
      resolve(GestureDisposition.rejected);
      return;
    }
    
    // END OF ADD THIS

    if (_state == _DragState.ready) {
      _state = _DragState.possible;
      _initialPosition =
          OffsetPair(global: event.position, local: event.localPosition);
      _initialButtons = event.buttons;
      _pendingDragOffset = OffsetPair.zero;
      _globalDistanceMoved = 0.0;
      _lastPendingEventTimestamp = event.timeStamp;
      _lastTransform = event.transform;
      _checkDown();
    } else if (_state == _DragState.accepted) {
      resolve(GestureDisposition.accepted);
    }
  }
  1. Copy and paste code from VerticalDragGestureRecognizer and rename toSinglePointerVerticalDragGestureRecognizer and make it extend SinglePointerDragRecognizer

  2. Do the same thing to Scrollable as done in the previous 2 steps, and replace all occurrences of VerticalDragGestureRecognizer with SinglePointerVerticalDragGestureRecognizer

  3. In your scroll view of choice (e.g SingleChildScrollView, ListView etc.) do the same thing and replace any occurrences of Scrollable with SinglePointerScrollable

  4. Use that scroll view in place of the built in one

OLD ANSWER

I ran into a very similar problem. I found this comment on a Github issue that gets it to a satisfactory level, but still isn't perfect:

https://github.com/flutter/flutter/issues/72996#issuecomment-918180838

here's a follow up comment on where you can find the code:

https://github.com/flutter/flutter/issues/72996#issuecomment-981446721

jms.heff
  • 86
  • 4
  • Hi, are you sure you are running the newest version of flutter? When I copied the DragGestureRecognizer the addAllowedPointer looked very different. I have tried adding the code you suggested, but it doesnt seem like it works. I have created a Github Repo: https://github.com/Lasslos/pinch_to_zoom_scrollable to demonstrate this. When you look in the console, you can still see that the List declares Victory when you Scroll with one Pointer and then add a second one. – Lasslos05 Oct 18 '22 at 13:57
  • Aha, since I may be on an older version of flutter, you needed to make the check after it adds the pointer to the `_velocityTrackers` map. I created a PR to show you what I'm talking about, but I didn't have time to test it, but I am getting noticeably better results in my app using this approach. – jms.heff Oct 18 '22 at 15:27
0

I used a gesture recognizer with EagerGestureRecognizer inside GoogleMap when I had a map in side a list and map supported pinch gestures see if this helps

 gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>[
          Factory<OneSequenceGestureRecognizer>(
            () => EagerGestureRecognizer(),
          ),
        ].toSet(),
rajithShetty
  • 311
  • 1
  • 3
  • 13
  • 1
    Thanks for that hint, unfortunately, the EagerGestureRecognizer breaks my ListView as it always declares Victory even if there is only one pointer on the screen. I am still awarding the bounty because of the effort you made to answer the question. – Lasslos05 Oct 16 '22 at 16:29