diff --git a/README.md b/README.md index 5ec4941..57ede74 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,27 @@ The default error widget shows a red `Icons.broken_image` and '🖼️💥🚫' ![Default Error Widget](https://github.com/thesmythgroup/easy_image_viewer/blob/main/demo_images/default-error-widget.png?raw=true "Default Error Widget") + +## Using Infinite Scroll for Multiple Images + +You can use `infinitelyScrollable: true` to create a "looping" effect with the images. This means that when you reach the end of the image list, you will be taken back to the beginning. This can be useful if you have a small number of images and want to allow the user to scroll through them continuously. + +```dart +MultiImageProvider multiImageProvider = MultiImageProvider([ + const NetworkImage("https://picsum.photos/id/1001/4912/3264"), + const NetworkImage("https://picsum.photos/id/1003/1181/1772"), + const NetworkImage("https://picsum.photos/id/1004/4912/3264"), + const NetworkImage("https://picsum.photos/id/1005/4912/3264") +]); + +showImageViewerPager(context, multiImageProvider, onPageChanged: (page) { + print("page changed to $page"); +}, onViewerDismissed: (page) { + print("dismissed while on page $page"); +}, infinitelyScrollable: true); +``` + + ## How to release a new version on pub.dev 1. Update the version number in `pubspec.yaml`. 2. Add an entry for the new version in `CHANGELOG.md`. diff --git a/example/lib/main.dart b/example/lib/main.dart index af22ae9..e32d5b5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -75,6 +75,15 @@ class _MyHomePageState extends State { showImageViewerPager(context, multiImageProvider, swipeDismissible: true, doubleTapZoomable: true); }), + ElevatedButton( + child: const Text("Show Multiple Images (Simple) with Infinite Scroll"), + onPressed: () { + MultiImageProvider multiImageProvider = + MultiImageProvider(_imageProviders); + showImageViewerPager(context, multiImageProvider, + swipeDismissible: true, doubleTapZoomable: true, + infinitelyScrollable: true); + }), ElevatedButton( child: const Text("Show Multiple Images (Custom)"), onPressed: () { diff --git a/lib/easy_image_viewer.dart b/lib/easy_image_viewer.dart index 37948ce..3d6214e 100644 --- a/lib/easy_image_viewer.dart +++ b/lib/easy_image_viewer.dart @@ -62,6 +62,7 @@ Future showImageViewer( /// The optional [useSafeArea] boolean defaults to false and is passed to [showDialog]. /// The optional [swipeDismissible] boolean defaults to false and allows swipe-down-to-dismiss. /// The optional [doubleTapZoomable] boolean defaults to false and allows double tap to zoom. +/// The optional [infinitelyScrollable] boolean defaults to false and allows infinite scrolling. /// The [backgroundColor] defaults to black, but can be set to any other color. /// The [closeButtonTooltip] text is displayed when the user long-presses on the /// close button and is used for accessibility. @@ -74,6 +75,7 @@ Future showImageViewerPager( bool useSafeArea = false, bool swipeDismissible = false, bool doubleTapZoomable = false, + bool infinitelyScrollable = false, Color backgroundColor = _defaultBackgroundColor, String closeButtonTooltip = _defaultCloseButtonTooltip, Color closeButtonColor = _defaultCloseButtonColor}) { @@ -93,6 +95,7 @@ Future showImageViewerPager( useSafeArea: useSafeArea, swipeDismissible: swipeDismissible, doubleTapZoomable: doubleTapZoomable, + infinitelyScrollable: infinitelyScrollable, backgroundColor: backgroundColor, closeButtonColor: closeButtonColor, closeButtonTooltip: closeButtonTooltip); diff --git a/lib/src/easy_image_view_pager.dart b/lib/src/easy_image_view_pager.dart index b3d9478..304dff0 100644 --- a/lib/src/easy_image_view_pager.dart +++ b/lib/src/easy_image_view_pager.dart @@ -17,6 +17,7 @@ class EasyImageViewPager extends StatefulWidget { final EasyImageProvider easyImageProvider; final PageController pageController; final bool doubleTapZoomable; + final bool infinitelyScrollable; /// Callback for when the scale has changed, only invoked at the end of /// an interaction. @@ -30,7 +31,8 @@ class EasyImageViewPager extends StatefulWidget { required this.easyImageProvider, required this.pageController, this.doubleTapZoomable = false, - this.onScaleChanged}) + this.onScaleChanged, + this.infinitelyScrollable = false,}) : super(key: key); @override @@ -47,13 +49,16 @@ class _EasyImageViewPagerState extends State { ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(), key: GlobalObjectKey(widget.easyImageProvider), - itemCount: widget.easyImageProvider.imageCount, + itemCount: widget.infinitelyScrollable + ? null + : widget.easyImageProvider.imageCount, controller: widget.pageController, scrollBehavior: MouseEnabledScrollBehavior(), itemBuilder: (context, index) { + final pageIndex = _getPageIndex(index); return EasyImageView.imageWidget( - widget.easyImageProvider.imageWidgetBuilder(context, index), - key: Key('easy_image_view_$index'), + widget.easyImageProvider.imageWidgetBuilder(context, pageIndex), + key: Key('easy_image_view_$pageIndex'), doubleTapZoomable: widget.doubleTapZoomable, onScaleChanged: (scale) { if (widget.onScaleChanged != null) { @@ -68,4 +73,14 @@ class _EasyImageViewPagerState extends State { }, ); } + + // If the infinitelyScrollable true, the page number is calculated modulo the + // total number of images, effectively creating a looping carousel effect. + // Otherwise, the index is returned as is. + int _getPageIndex(int index) { + if (widget.infinitelyScrollable) { + return index % widget.easyImageProvider.imageCount; + } + return index; + } } diff --git a/lib/src/easy_image_viewer_dismissible_dialog.dart b/lib/src/easy_image_viewer_dismissible_dialog.dart index 5568bf2..8c180b2 100644 --- a/lib/src/easy_image_viewer_dismissible_dialog.dart +++ b/lib/src/easy_image_viewer_dismissible_dialog.dart @@ -19,6 +19,7 @@ class EasyImageViewerDismissibleDialog extends StatefulWidget { final Color backgroundColor; final String closeButtonTooltip; final Color closeButtonColor; + final bool infinitelyScrollable; /// Refer to [showImageViewerPager] for the arguments const EasyImageViewerDismissibleDialog(this.imageProvider, @@ -29,6 +30,7 @@ class EasyImageViewerDismissibleDialog extends StatefulWidget { this.useSafeArea = false, this.swipeDismissible = false, this.doubleTapZoomable = false, + this.infinitelyScrollable = false, required this.backgroundColor, required this.closeButtonTooltip, required this.closeButtonColor}) @@ -60,7 +62,7 @@ class _EasyImageViewerDismissibleDialogState PageController(initialPage: widget.imageProvider.initialIndex); if (widget.onPageChanged != null) { _internalPageChangeListener = () { - widget.onPageChanged!(_pageController.page?.round() ?? 0); + widget.onPageChanged!(_getCurrentPage()); }; _pageController.addListener(_internalPageChangeListener!); } @@ -100,6 +102,7 @@ class _EasyImageViewerDismissibleDialogState easyImageProvider: widget.imageProvider, pageController: _pageController, doubleTapZoomable: widget.doubleTapZoomable, + infinitelyScrollable: widget.infinitelyScrollable, onScaleChanged: (scale) { setState(() { _dismissDirection = scale <= 1.0 @@ -145,7 +148,7 @@ class _EasyImageViewerDismissibleDialogState // through the "x" close button, or through swipe-to-dismiss. void _handleDismissal() { if (widget.onViewerDismissed != null) { - widget.onViewerDismissed!(_pageController.page?.round() ?? 0); + widget.onViewerDismissed!(_getCurrentPage()); } if (widget.immersive) { @@ -155,4 +158,15 @@ class _EasyImageViewerDismissibleDialogState _pageController.removeListener(_internalPageChangeListener!); } } + + // Returns the current page number. + // If the infinitelyScrollable true, the page number is calculated modulo the + // total number of images, effectively creating a looping carousel effect. + int _getCurrentPage() { + var currentPage = _pageController.page?.round() ?? 0; + if (widget.infinitelyScrollable) { + currentPage = currentPage % widget.imageProvider.imageCount; + } + return currentPage; + } } diff --git a/test/easy_image_viewer_test.dart b/test/easy_image_viewer_test.dart index bc941b0..e341392 100644 --- a/test/easy_image_viewer_test.dart +++ b/test/easy_image_viewer_test.dart @@ -124,6 +124,80 @@ void main() { expect(pageOnDismissal, 2); }); + + testWidgets('should have a PageView of infinite images and invoke callbacks', + (WidgetTester tester) async { + List imageProviders = List.empty(growable: true); + final context = await createTestBuildContext(tester); + bool dismissed = false; + int currentPage = -1; + int pageOnDismissal = -1; + + await tester.runAsync(() async { + const colors = [ + Colors.amber, + Colors.red, + Colors.green, + Colors.blue, + Colors.teal + ]; + imageProviders = + await Future.wait(colors.map((color) => createColorImageProvider(color))); + }); + + final multiImageProvider = MultiImageProvider(imageProviders); + + final dialogFuture = showImageViewerPager(context, multiImageProvider, + onPageChanged: (page) { + currentPage = page; + }, onViewerDismissed: (page) { + dismissed = true; + pageOnDismissal = page; + }, infinitelyScrollable: true); + await tester.pumpAndSettle(); + + // Create the Finders. + final pageViewFinder = find.byKey(GlobalObjectKey(multiImageProvider)); + final closeButtonFinder = find.byIcon(Icons.close); + + // Check existence + expect(pageViewFinder, findsOneWidget); + expect(closeButtonFinder, findsOneWidget); + + // Swipe to second image + await tester.drag(pageViewFinder, const Offset(-501.0, 0.0)); + await tester.pumpAndSettle(); + expect(currentPage, 1); + + // Swipe to third image + await tester.drag(pageViewFinder, const Offset(-501.0, 0.0)); + await tester.pumpAndSettle(); + expect(currentPage, 2); + + // Swipe to fourth image + await tester.drag(pageViewFinder, const Offset(-501.0, 0.0)); + await tester.pumpAndSettle(); + expect(currentPage, 3); + + // Swipe to fifth image + await tester.drag(pageViewFinder, const Offset(-501.0, 0.0)); + await tester.pumpAndSettle(); + expect(currentPage, 4); + + // Swipe to first image + await tester.drag(pageViewFinder, const Offset(-501.0, 0.0)); + await tester.pumpAndSettle(); + expect(currentPage, 0); + + // Dismiss the dialog + await tester.tap(closeButtonFinder); + + await dialogFuture; + + expect(dismissed, true); + expect(pageOnDismissal, 0); + }); + testWidgets('should invoke callbacks when dismissed with a swipe', (WidgetTester tester) async { List imageProviders = List.empty(growable: true); @@ -219,6 +293,57 @@ void main() { expect(pageOnDismissal, 2); }); + testWidgets('should respect the initialIndex when infinitelyScrollable is true', + (WidgetTester tester) async { + List imageProviders = List.empty(growable: true); + final context = await createTestBuildContext(tester); + bool dismissed = false; + int pageOnDismissal = -1; + + await tester.runAsync(() async { + const colors = [ + Colors.amber, + Colors.red, + Colors.green, + Colors.blue, + Colors.teal + ]; + imageProviders = + await Future.wait(colors.map((color) => createColorImageProvider(color))); + }); + + final multiImageProvider = + MultiImageProvider(imageProviders, initialIndex: 2); + + final dialogFuture = showImageViewerPager(context, multiImageProvider, + onViewerDismissed: (page) { + dismissed = true; + pageOnDismissal = page; + },infinitelyScrollable: true); + await tester.pumpAndSettle(); + + // Create the Finders. + final closeButtonFinder = find.byIcon(Icons.close); + + // Check default closeButtonColor + IconButton closeButton = + tester.firstWidget(find.widgetWithIcon(IconButton, Icons.close)); + expect(closeButton.color, Colors.white); + + // Check default dialog backgroundColor + Dialog dialog = tester + .firstWidget(find.byWidgetPredicate((widget) => widget is Dialog)); + expect(dialog.backgroundColor, Colors.black); + + // Dismiss the dialog + await tester.tap(closeButtonFinder); + + await dialogFuture; + + expect(dismissed, true); + expect(pageOnDismissal, 2); + }); + testWidgets('should respect the backgroundColor and closeButtonColor', (WidgetTester tester) async { List imageProviders = List.empty(growable: true);