flutterdartinteractivepinchzoom

Flutter - How to create List of zoomable images?


I'm trying to create a list of zoomable images inside the list view but the problem is when the user tries to zoom in the layout becomes confusing and laggy because the device can't detect if the user wants to scroll the list or if he just wants to zoom in!

I want to do the same as the Instagram multi-picture in one post (pinch zooming).

here is the code, i'm writing this in Sliver to adapter because it's a child of custom scroll view.

             SliverToBoxAdapter(
            child: FutureBuilder<ProductDataModel>(
              future: Provider.of<ProductViewModel>(context, listen: false)
                  .getProductDetails(context, widget.productId),
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const Center(
                    child: Text('Loading...'),
                  );
                } else if (snapshot.hasData) {
                  ProductDataModel? productData = snapshot.data;

                  /// just to filter something

                  List<String> imagesPath = [];
                  snapshot.data!.product!.files!
                      .map((e) => imagesPath.add(e.path!))
                      .toList();

                  snapshot.data!.product!.options!.map((e) {
                    e.name == 'Color'
                        ? e.values!
                            .map((s) => imagesPath.add(s.optionImage!))
                            .toList()
                        : null;
                  }).toList();

                  /// start of the list view

                  return SizedBox(
                    height: 350.h,
                    child: ListView.builder(
                      itemCount: imagesPath.length,
                      physics: const BouncingScrollPhysics(),
                      scrollDirection: Axis.horizontal,
                      itemBuilder: (context, index) {
                        return ZoomOverlay(
                          twoTouchOnly: true,
                          minScale: 0.8,
                          maxScale: 4,
                          child: CachedNetworkImage(
                            alignment: Alignment.center,
                            width: ScreenUtil.defaultSize.width,
                            imageUrl: imagesPath[index],
                            progressIndicatorBuilder:
                                (context, url, downloadProgress) =>
                                    const DaraghmehShimmer(),
                            errorWidget: (context, url, error) =>
                                const Icon(Icons.error),
                          ),
                        );
                      },
                    ),
                  );
                }

                return const SizedBox();
              },
            ),
          ),

Solution

  • ok, so after a long time of trying, i found the solution...

    PinchZooming

    class PinchZooming extends StatefulWidget {
    final Widget child;
    final double maxScale, minScale;
    final Duration resetDuration;
    final bool zoomEnabled;
    final Function? onZoomStart, onZoomEnd;
    
    
    
    const PinchZooming(
      {Key? key,
      required this.child,
      this.resetDuration = const Duration(milliseconds: 100),
      this.maxScale = 4.0,
      this.minScale = 1.0,
      this.zoomEnabled = true,
      this.onZoomStart,
      this.onZoomEnd})
      : assert(maxScale != 0 && minScale != 0 && maxScale > minScale,
            'Either min or max scale value equal zero or max scale is less 
     than min scale'),
        super(key: key);
    
    @override
    _PinchZoomingState createState() => _PinchZoomingState();
    }
    
    class _PinchZoomingState extends State<PinchZooming>
    with SingleTickerProviderStateMixin {
    final TransformationController _transformationController =
      TransformationController();
    
    late Animation<Matrix4> _animation;
    
    late AnimationController _controller;
    
    OverlayEntry? _entry;
    
      @override
         void initState() {
       super.initState();
       _controller = AnimationController(
        duration: widget.resetDuration,
        vsync: this,
       );
        _animation = Matrix4Tween().animate(_controller);
        _controller
        .addListener(() => _transformationController.value = 
       _animation.value);
       _controller.addStatusListener((status) {
         if (status == AnimationStatus.completed) {
        removeOverlay();
       }
      });
    }
    
    @override
    void dispose() {
    _controller.dispose();
    super.dispose();
    }
    
    void showOverlay(BuildContext context) {
      final RenderBox _renderBox = context.findRenderObject()! as RenderBox;
      final Offset _offset = _renderBox.localToGlobal(Offset.zero);
      removeOverlay();
      _entry = OverlayEntry(
        builder: (c) => Stack(
        children: [
          Positioned.fill(
              child:
                  Opacity(opacity: 0.5, child: Container(color: 
          Colors.black))),
          Positioned(
            left: _offset.dx,
            top: _offset.dy,
            child: InteractiveViewer(
              minScale: widget.minScale,
              clipBehavior: Clip.none,
              scaleEnabled: widget.zoomEnabled,
              maxScale: widget.maxScale,
              panEnabled: false,
              onInteractionStart: (ScaleStartDetails details) {
                if (details.pointerCount < 2) return;
                if (_entry == null) {
                  showOverlay(context);
                }
              },
              onInteractionEnd: (_) => restAnimation(),
              transformationController: _transformationController,
              child: widget.child,
            ),
          ),
        ],
      ),
    );
         final OverlayState? _overlay = Overlay.of(context);
        _overlay!.insert(_entry!);
      }
    
       void removeOverlay() {
         _entry?.remove();
        _entry = null;
        }
    
        void restAnimation() {
         _animation = Matrix4Tween(
            begin: _transformationController.value, end: Matrix4.identity())
             .animate(
            CurvedAnimation(parent: _controller, curve: Curves.easeInBack));
         _controller.forward(from: 0);
        }
    
       @override
       Widget build(BuildContext context) {
        return InteractiveViewer(
        child: widget.child,
         clipBehavior: Clip.none,
         minScale: widget.minScale,
         scaleEnabled: widget.zoomEnabled,
          maxScale: widget.maxScale,
         panEnabled: false,
         onInteractionStart: (ScaleStartDetails details) {
        if (details.pointerCount < 2) return;
        if (_entry == null) {
          showOverlay(context);
        }
        if (widget.onZoomStart != null) {
          widget.onZoomStart!();
        }
      },
      onInteractionUpdate: (details) {
        if (_entry == null) return;
        _entry!.markNeedsBuild();
      },
      onInteractionEnd: (details) {
        if (details.pointerCount != 1) return;
        restAnimation();
        if (widget.onZoomEnd != null) {
          widget.onZoomEnd!();
          }
      },
         transformationController: _transformationController,
       );
      }
     }
    

    TouchCountRecognizer

       class TouchCountRecognizer extends OneSequenceGestureRecognizer {
       TouchCountRecognizer(this.onMultiTouchUpdated);
    
       Function(bool) onMultiTouchUpdated;
       int touchcount = 0;
    
       @override
       void addPointer(PointerDownEvent event) {
       startTrackingPointer(event.pointer);
        if (touchcount < 1) {
        //resolve(GestureDisposition.rejected);
        //_p = event.pointer;
    
        onMultiTouchUpdated(false);
         } else {
         onMultiTouchUpdated(true);
         //resolve(GestureDisposition.accepted);
         }
         touchcount++;
        }
    
        @override
        String get debugDescription => 'touch count recognizer';
    
        @override
        void didStopTrackingLastPointer(int pointer) {}
    
       @override
       void handleEvent(PointerEvent event) {
        if (!event.down) {
         touchcount--;
          if (touchcount < 1) {
           onMultiTouchUpdated(false);
         }
        }  
       }
     }
    

    then i companied these two classes together like this...

    return SizedBox(
                        height: 350.h,
                        child: Consumer<ProductViewModel>(
                          builder: (_, state, child) => RawGestureDetector(
                            gestures: <Type, GestureRecognizerFactory>{
                              TouchCountRecognizer:
                                  GestureRecognizerFactoryWithHandlers<
                                      TouchCountRecognizer>(
                                () => TouchCountRecognizer(
                                    state.onMultiTouchUpdated),
                                (TouchCountRecognizer instance) {},
                              ),
                            },
                            child: NotificationListener(
                              onNotification: (notification) {
                                if (notification is ScrollNotification) {
                                  WidgetsBinding.instance
                                      .addPostFrameCallback((timeStamp) {
                                    state.scrolling = true;
    
                                    if (state.scrolling == true &&
                                        state.multiTouch == false) {
                                      state.stopZoom();
                                    } else if (state.scrolling == false &&
                                        state.multiTouch == true) {
                                      state.stopZoom();
                                    } else if (state.scrolling == true &&
                                        state.multiTouch == true) {
                                      state.startZoom();
                                    }
                                  });
                                }
                                if (notification is ScrollUpdateNotification) {}
                                if (notification is ScrollEndNotification) {
                                  WidgetsBinding.instance
                                      .addPostFrameCallback((timeStamp) {
                                    state.scrolling = false;
                                    if (state.scrolling == true &&
                                        state.multiTouch == false) {
                                      state.stopZoom();
                                    } else if (state.scrolling == false &&
                                        state.multiTouch == true) {
                                      state.stopZoom();
                                    } else if (state.scrolling == true &&
                                        state.multiTouch == true) {
                                      state.startZoom();
                                    }
                                  });
                                }
    
                                return state.scrolling;
                              },
                              child: ListView.builder(
                                shrinkWrap: false,
                                physics: state.imagePagerScrollPhysics,
                                itemCount: imagesPath.length,
                                scrollDirection: Axis.horizontal,
                                itemBuilder: (context, index) {
                                  return PinchZooming(
                                    zoomEnabled: state.isZoomEnabled,
                                    onZoomStart: () => state.startZoom(),
                                    onZoomEnd: () => state.stopZoom(),
                                    child: CachedNetworkImage(
                                      alignment: Alignment.center,
                                      width: ScreenUtil.defaultSize.width,
                                      imageUrl: imagesPath[index],
                                      progressIndicatorBuilder:
                                          (context, url, downloadProgress) =>
                                              const DaraghmehShimmer(),
                                      errorWidget: (context, url, error) =>
                                          const Icon(Icons.error),
                                    ),
                                  );
                                },
                              ),
                            ),
                          ),
                        ),
                      );