fluttercandies / extended_image

A powerful official extension library of image, which support placeholder(loading)/ failed state, cache network, zoom pan image, photo view, slide out page, editor(crop,rotate,flip), paint custom etc.
https://fluttercandies.github.io/extended_image/
MIT License
1.93k stars 503 forks source link

[Bug report] onDragEnd gesture -> Null check operator used on a null value #601

Closed EgorK0rshun closed 1 year ago

EgorK0rshun commented 1 year ago

Version

8.0.2

Platforms

dart, Android, iOS

Device Model

All devices, Galaxy A12, Galaxy A32, Galaxy A13, Redmi 8A, Redmi 9C, Redmi Note 8

flutter info

flutter doctor -v
[✓] Flutter (Channel stable, 3.10.6, on macOS 13.0.1 22A400 darwin-arm64, locale ru-BY)
    • Flutter version 3.10.6 on channel stable at /Users/k/develop/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision f468f3366c (5 weeks ago), 2023-07-12 15:19:05 -0700
    • Engine revision cdbeda788a
    • Dart version 3.0.6
    • DevTools version 2.23.1

[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.1)
    • Android SDK at /Users/k/Library/Android/sdk
    • Platform android-33, build-tools 33.0.1
    • Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 14.2)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 14C18
    • CocoaPods version 1.11.3

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2021.3)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)

[✓] Android Studio (version 2021.3)
    • Android Studio at /Users/k/Library/Application Support/JetBrains/Toolbox/apps/AndroidStudio/ch-0/213.7172.25.2113.9123335/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)

[✓] VS Code (version 1.81.0)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.56.0

[✓] Connected device (3 available)
    • SM A137F (mobile) • RF8T80AXGPY • android-arm    • Android 13 (API 33)
    • macOS (desktop)   • macos       • darwin-arm64   • macOS 13.0.1 22A400 darwin-arm64
    • Chrome (web)      • chrome      • web-javascript • Google Chrome 115.0.5790.170

[✓] Network resources
    • All expected network resources are available.

• No issues found!

How to reproduce?

Use ExtendedImageGesturePageView.builder and try to execute onDrag gesture. The exception will be throw for onDragEnd callback

Logs

UnhandledFlutterException: Null check operator used on a null value
       at extended_image.src.gesture.page_view.gesture_page_view.dart.ExtendedImageGesturePageViewState.onDragEnd(gesture_page_view.dart:426)
       at extended_image.src.gesture_detector.drag.dart.ExtendedDragGestureRecognizer._checkEnd.<fn>(drag.dart:623)
       at flutter.src.gestures.recognizer.dart.GestureRecognizer.invokeCallback(recognizer.dart:253)
       at extended_image.src.gesture_detector.drag.dart.ExtendedDragGestureRecognizer._checkEnd(drag.dart:623)
       at extended_image.src.gesture_detector.drag.dart.ExtendedDragGestureRecognizer.didStopTrackingLastPointer(drag.dart:526)
       at flutter.src.gestures.recognizer.dart.OneSequenceGestureRecognizer.stopTrackingPointer(recognizer.dart:446)
       at extended_image.src.gesture_detector.drag.dart.ExtendedDragGestureRecognizer._giveUpPointer(drag.dart:535)
       at extended_image.src.gesture_detector.drag.dart.ExtendedDragGestureRecognizer.handleEvent(drag.dart:449)
       at flutter.src.gestures.pointer_router.dart.PointerRouter._dispatch(pointer_router.dart:98)
       at flutter.src.gestures.pointer_router.dart.PointerRouter._dispatchEventToRoutes.<fn>(pointer_router.dart:143)
       at collection-patch.compact_hash.dart._LinkedHashMapMixin.forEach(compact_hash.dart:625)
       at flutter.src.gestures.pointer_router.dart.PointerRouter._dispatchEventToRoutes(pointer_router.dart:141)
       at flutter.src.gestures.pointer_router.dart.PointerRouter.route(pointer_router.dart:127)
       at flutter.src.gestures.binding.dart.GestureBinding.handleEvent(binding.dart:460)
       at flutter.src.gestures.binding.dart.GestureBinding.dispatchEvent(binding.dart:440)
       at flutter.src.rendering.binding.dart.RendererBinding.dispatchEvent(binding.dart:336)
       at flutter.src.gestures.binding.dart.GestureBinding._handlePointerEventImmediately(binding.dart:395)
       at flutter.src.gestures.binding.dart.GestureBinding.handlePointerEvent(binding.dart:357)
       at flutter.src.gestures.binding.dart.GestureBinding._flushPointerEventQueue(binding.dart:314)
       at flutter.src.gestures.binding.dart.GestureBinding._handlePointerDataPacket(binding.dart:295)
       at async.zone.dart._rootRunUnary(zone.dart:1414)
       at async.zone.dart._CustomZone.runUnary(zone.dart:1307)
       at async.zone.dart._CustomZone.runUnaryGuarded(zone.dart:1216)
       at ui.hooks.dart._invoke1(hooks.dart:166)
       at ui.platform_dispatcher.dart.PlatformDispatcher._dispatchPointerDataPacket(platform_dispatcher.dart:361)
       at ui.hooks.dart._dispatchPointerDataPacket(hooks.dart:91)

Example code (optional)

child: Stack(
              children: <Widget>[
                ValueListenableBuilder<Map<String, ImageDownloadingState>>(
                  valueListenable: context.read<Model>().imageDownloadingStatesValueNotifier,
                  builder: (_, Map<String, ImageDownloadingState> imageDownloadingStates, __) {
                    return ExtendedImageGesturePageView.builder(
                      controller: _extendedPageController,
                      physics: _ImageScrollPhysics(),
                      itemCount: _imagePaths.length,
                      onPageChanged: (int index) {
                   _onPageChanged();
                        _startPrecachingImages(index);
                      },
                      canScrollPage: (GestureDetails? details) {
                        if (details?.totalScale == 1.0) {
                          return true;
                        }
                        return false;
                      },
                      itemBuilder: (BuildContext context, int index) {
                        final Widget image = AnimatedScale(
                          key: Key(_imagePaths[index]),
                          duration: _deleteAnimationDuration,
                          scale: _someConditionalForImage[index] ? 0 : 1,
                          child: _someConditional
                              ? const FractionallySizedBox(
                                  widthFactor: 0.75,
                                  child: ImagePreviewProgress(
                                    size: double.infinity,
                                  ),
                                )
                              : ExtendedImage(
                                  key: Key(_imagePaths[index] + _shouldBeFullscreen.toString()),
                                  image: ExtendedFileImageProvider(File(_imagePaths[index])),
                                  fit: BoxFit.contain,
                                  mode: ExtendedImageMode.gesture,
                                  initGestureConfigHandler: (ExtendedImageState state) {
                                    return GestureConfig(
                                      animationMinScale: 1.0,
                                      minScale: 1,
                                      inPageView: true,
                                      cacheGesture: false,
                                    );
                                  },
                                  loadStateChanged: (ExtendedImageState state) {
                                    switch (state.extendedImageLoadState) {
                                      case LoadState.loading:
                                        return null;
                                      case LoadState.completed:
                                        _imageLoaded = true;
                                        return null;
                                      case LoadState.failed:
                                        _imageLoaded = false;
                                        // remove memory cached
                                        state.imageProvider.evict();
                                        return const FractionallySizedBox(
                                          widthFactor: 0.75,
                                          child: ImagePreviewError(
                                            size: double.infinity,
                                          ),
                                        );
                                    }
                                  },
                                ),
                        );
                        ...
}

    void _startPrecachingImages(int currentImageIndex) {
    final List<String> filesToPrecache = <String>[];
    if (currentImageIndex < _imagePaths.length - 1) {
      filesToPrecache.add(_imagePaths[currentImageIndex + 1]);
    }
    if (currentImageIndex != 0) {
      filesToPrecache.add(_imagePaths[currentImageIndex - 1]);
    }
    for (String path in filesToPrecache) {
      precacheImage(ExtendedFileImageProvider(File(path)), context);
    }
  } 

Contact

https://github.com/EgorK0rshun, korshun.dev@gmail.com

zmtzawqlp commented 1 year ago

could you provide a simple runnable demo to reproduce your issue?

EgorK0rshun commented 1 year ago

@zmtzawqlp , unfortunately no. This is reproduced by real users. i only have stats. And in code of library onDragUpdate have ambiguous implementation:

  `void onDragUpdate(DragUpdateDetails details) {
    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    //final Offset delta = details.delta;
    if (!widget.canScrollPage(extendedImageGestureState?.gestureDetails)) {
      return;
    }

    _drag?.update(details);

//     return;

//     if (extendedImageGestureState != null) {
//       final GestureDetails? gestureDetails =
//           extendedImageGestureState!.gestureDetails;
//       if (gestureDetails != null) {
//         final int currentPage = pageController.page!.round();
// //        bool pageChanging = false;
// //
// //        if (widget.scrollDirection == Axis.horizontal) {
// //          if (delta.dx != 0.0) {
// //            if (delta.dx < 0) {
// //              pageChanging = pageController.page > currentPage;
// //            } else {
// //              pageChanging = pageController.page < currentPage;
// //            }
// //          }
// //        } else {
// //          if (delta.dy != 0.0) {
// //            if (delta.dy < 0) {
// //              pageChanging = pageController.page < currentPage;
// //            } else {
// //              pageChanging = pageController.page > currentPage;
// //            }
// //          }
// //        }

//         if ((gestureDetails.movePage(delta, widget.scrollDirection) ||
//                 (currentPage != pageController.page)) &&
//             widget.canMovePage(gestureDetails)) {
//           _drag?.update(details);
//         } else {
//           if (currentPage == pageController.page) {
//             extendedImageGestureState!.gestureDetails = GestureDetails(
//                 offset: gestureDetails.offset! +
//                     delta *
//                         extendedImageGestureState!.imageGestureConfig!.speed,
//                 totalScale: gestureDetails.totalScale,
//                 gestureDetails: gestureDetails);
//           }
//         }
//       } else {
//         _drag?.update(details);
//       }
//     } else {
//       _drag?.update(details);
//     }
  }

but ok. Its work. And here used ? operator ( _drag?.update(details);).

In the method onDragEnd you use bang operator:

    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    if (!widget.canScrollPage(extendedImageGestureState?.gestureDetails)) {
      _drag!.end(DragEndDetails(primaryVelocity: 0.0));
      return;
    }
    _drag!.end(details);
    assert(_drag == null);
    // return;
    // DragEndDetails temp = details;
    // if (extendedImageGestureState != null) {
    //   final GestureDetails? gestureDetails =
    //       extendedImageGestureState!.gestureDetails;
    //   final int currentPage = pageController.page!.round();
    //   final bool movePage = pageController.page != currentPage;

    //   if (!widget.canMovePage(gestureDetails)) {
    //     //stop
    //     temp = DragEndDetails(primaryVelocity: 0.0);
    //   }

    //   /// stop when zoom in, so that it will not move to next/previous page
    //   if (!movePage &&
    //       gestureDetails != null &&
    //       gestureDetails.totalScale! > 1.0 &&
    //       (gestureDetails.computeHorizontalBoundary ||
    //           gestureDetails.computeVerticalBoundary)) {
    //     //stop
    //     temp = DragEndDetails(primaryVelocity: 0.0);

    //     // get magnitude from gesture velocity
    //     final double magnitude = details.velocity.pixelsPerSecond.distance;

    //     // do a significant magnitude
    //     if (magnitude.greaterThanOrEqualTo(minMagnitude)) {
    //       Offset direction = details.velocity.pixelsPerSecond /
    //           magnitude *
    //           (extendedImageGestureState!.imageGestureConfig!.inertialSpeed);

    //       if (widget.scrollDirection == Axis.horizontal) {
    //         direction = Offset(direction.dx, 0.0);
    //       } else {
    //         direction = Offset(0.0, direction.dy);
    //       }

    //       _gestureAnimation.animationOffset(
    //           gestureDetails.offset, gestureDetails.offset! + direction);
    //     }
    //   }
    // }

    // _drag!.end(temp);

    // assert(_drag == null);
  }

maybe error here _drag!.end(details);

zmtzawqlp commented 1 year ago

_drag should not be null at that moment , it's better to reproduce it so that i can bug it.

EgorK0rshun commented 1 year ago

no, drug = null, because u a set null when start onDragStart gesture:

 void _disposeDrag() {
    _drag = null;
  }

let me fork it, fix this string, look at the stats, and if it helps, I'll make a requisition for the extended_image

zmtzawqlp commented 1 year ago

no, drug = null, because u a set null when start onDragStart gesture:

 void _disposeDrag() {
    _drag = null;
  }

let me fork it, fix this string, look at the stats, and if it helps, I'll make a requisition for the extended_image

i mean that it should not be null at that moment(onDragEnd). _disposeDrag callback is called after onDragEnd

EgorK0rshun commented 1 year ago

@zmtzawqlp , shouldn't, but there is. analytics and stacktrace of the exception says that this place is null

zmtzawqlp commented 1 year ago

@zmtzawqlp , shouldn't, but there is. analytics and stacktrace of the exception says that this place is null

so we should better find out the case of this issue, not simplely use '?' instead of '!'.

EgorK0rshun commented 1 year ago

@zmtzawqlp , so it is, after all, for the reason that he wrote above: at the beginning of the gesture, _drag is dispositions. you can debug. according to the order of calls, _drag becomes null in the course of the gesture execution

EgorK0rshun commented 1 year ago

@zmtzawqlp , please review https://github.com/fluttercandies/extended_image/pull/620

zmtzawqlp commented 1 year ago

@zmtzawqlp , please review #620

Hi guy, thank you very much for reporting this issue. and I think we should find the real reason for this problem. I only received a report about this issue from you for now. I added an assert here because it should not be null. If it happens, we should find the real reason to solve it instead of avoiding it directly.

EgorK0rshun commented 1 year ago

@zmtzawqlp I described the reason above. The changes in this request helped to get rid of a bug that several thousand users encountered. It's all about the _disposeDrag method. in all references to the drag in this class is used ? , only for onDragUpdate you use ! Flutter generally recommends avoiding bang operators. You can try to look deeper if you are not sure about the correctness of my wording, but I would ask you not to delay, because maintaining a fork of a third-party library is quite expensive in terms of time and resources. In the current implementation, everything works correctly. Doesn't lead to any additional errors.

EgorK0rshun commented 1 year ago
Снимок экрана 2023-09-06 в 15 24 28

here is a diagram showing that when switching to a new version of the application, in which the line was corrected, as in the attached request ( https://github.com/fluttercandies/extended_image/pull/620 ) , the error disappears. the smoothness of the graphics is ensured by the gradual rolling out of the new version.

svyatoslavb-digitalchemy commented 1 year ago

Hi, It is not recommended to use the bang operator to interact with gestures. When checking using null-aware access, the method will simply fail if the value is null.

zmtzawqlp commented 1 year ago

Those codes are from https://github.com/flutter/flutter/blob/e8ff2aed31692142fb3494e2ee8b5b11d3e28693/packages/flutter/lib/src/widgets/scrollable.dart#L831

it is already use '?' here, and I believe our modification won't introduce any other issues. Thanks again for your pull request and explanation. LGTM