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

Infinite scroll for multiple images #59

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
9 changes: 9 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ class _MyHomePageState extends State<MyHomePage> {
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: () {
Expand Down
3 changes: 3 additions & 0 deletions lib/easy_image_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Future<Dialog?> 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.
Expand All @@ -74,6 +75,7 @@ Future<Dialog?> showImageViewerPager(
bool useSafeArea = false,
bool swipeDismissible = false,
bool doubleTapZoomable = false,
bool infinitelyScrollable = false,
Color backgroundColor = _defaultBackgroundColor,
String closeButtonTooltip = _defaultCloseButtonTooltip,
Color closeButtonColor = _defaultCloseButtonColor}) {
Expand All @@ -93,6 +95,7 @@ Future<Dialog?> showImageViewerPager(
useSafeArea: useSafeArea,
swipeDismissible: swipeDismissible,
doubleTapZoomable: doubleTapZoomable,
infinitelyScrollable: infinitelyScrollable,
backgroundColor: backgroundColor,
closeButtonColor: closeButtonColor,
closeButtonTooltip: closeButtonTooltip);
Expand Down
23 changes: 19 additions & 4 deletions lib/src/easy_image_view_pager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -47,13 +49,16 @@ class _EasyImageViewPagerState extends State<EasyImageViewPager> {
? 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) {
Expand All @@ -68,4 +73,14 @@ class _EasyImageViewPagerState extends State<EasyImageViewPager> {
},
);
}

// 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;
}
}
18 changes: 16 additions & 2 deletions lib/src/easy_image_viewer_dismissible_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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})
Expand Down Expand Up @@ -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!);
}
Expand Down Expand Up @@ -100,6 +102,7 @@ class _EasyImageViewerDismissibleDialogState
easyImageProvider: widget.imageProvider,
pageController: _pageController,
doubleTapZoomable: widget.doubleTapZoomable,
infinitelyScrollable: widget.infinitelyScrollable,
onScaleChanged: (scale) {
setState(() {
_dismissDirection = scale <= 1.0
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
}
125 changes: 125 additions & 0 deletions test/easy_image_viewer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,80 @@ void main() {
expect(pageOnDismissal, 2);
});


testWidgets('should have a PageView of infinite images and invoke callbacks',
(WidgetTester tester) async {
List<ImageProvider> 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<ImageProvider> imageProviders = List.empty(growable: true);
Expand Down Expand Up @@ -219,6 +293,57 @@ void main() {
expect(pageOnDismissal, 2);
});

testWidgets('should respect the initialIndex when infinitelyScrollable is true',
(WidgetTester tester) async {
List<ImageProvider> 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<ImageProvider> imageProviders = List.empty(growable: true);
Expand Down
Loading