Skip to content

Commit

Permalink
Add gallery viewer for info images (#238)
Browse files Browse the repository at this point in the history
Co-authored-by: Robbendebiene <[email protected]>
  • Loading branch information
yulieth9109 and Robbendebiene authored Feb 28, 2024
1 parent 2a10aec commit fd5c823
Show file tree
Hide file tree
Showing 4 changed files with 328 additions and 31 deletions.
46 changes: 46 additions & 0 deletions lib/widgets/derived_animation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';

/// Adapter class to turn a [ChangeNotifier] into an [Animation].
///
/// This forwards the notifications of the [ChangeNotifier] and derives
/// the [Animation.value] using the provided [transformer] callback.
///
/// This is almost identical to https://api.flutter.dev/flutter/animation/Animation/Animation.fromValueListenable.html
class DerivedAnimation<T extends ChangeNotifier,V> extends Animation<V> {
final T _notifier;
final V Function(T) _transformer;

DerivedAnimation({
required T notifier,
required V Function(T) transformer,
}) :
_notifier = notifier,
_transformer = transformer;

@override
void addListener(VoidCallback listener) {
_notifier.addListener(listener);
}

@override
void addStatusListener(AnimationStatusListener listener) {
// status will never change.
}

@override
void removeListener(VoidCallback listener) {
_notifier.removeListener(listener);
}

@override
void removeStatusListener(AnimationStatusListener listener) {
// status will never change.
}

@override
AnimationStatus get status => AnimationStatus.forward;

@override
V get value => _transformer.call(_notifier);
}
270 changes: 270 additions & 0 deletions lib/widgets/gallery_viewer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import '/commons/themes.dart';
import '/widgets/hero_viewer.dart';
import '/widgets/derived_animation.dart';

class GalleryViewer extends StatelessWidget {

final List<String> images;

const GalleryViewer({required this.images, super.key });

@override
Widget build(BuildContext context) {
const horizontalPadding = EdgeInsets.symmetric(horizontal: 20);
final List<UniqueKey> imagesKeys = List.generate(images.length, (index) => UniqueKey());

return ListView.separated(
padding: horizontalPadding,
clipBehavior: Clip.none,
physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (context, index) => const SizedBox(width: 10),
itemBuilder: (context, index) {
return ClipRRect(
borderRadius: BorderRadius.circular(Default.borderRadius),
// hero viewer cannot be used in frame builder
// because the builder may be called after the page route transition starts
child: HeroViewer(
pageBuilder: (BuildContext context, Widget child){
return ColoredBox(
color: Theme.of(context).colorScheme.background,
child: GalleryNavigator(
images: images,
imagesKeys: imagesKeys,
initialIndex: index,
),
);
},
tag: imagesKeys[index],
child: Image.asset(
images[index],
errorBuilder: (context, _, __) {
return Image.asset(
'assets/images/placeholder_image.png',
);
},
),
),
);
},
);
}
}

class GalleryNavigator extends StatefulWidget {
final List<String> images;
final List<UniqueKey> imagesKeys;
final int initialIndex;

const GalleryNavigator({
required this.images,
required this.imagesKeys,
this.initialIndex = 0,
super.key,}
);

@override
State<GalleryNavigator> createState() => _GalleryNavigatorState();
}

class _GalleryNavigatorState extends State<GalleryNavigator>{
int _pointerCount = 0;

late final PageController _pageController;
late final DerivedAnimation<PageController, double> _rightAnimation ;
late final DerivedAnimation<PageController, double> _leftAnimation;

final List<TransformationController> _transformationControllers = [];

@override
void initState() {
super.initState();
_pageController = PageController(initialPage: widget.initialIndex);
_leftAnimation = DerivedAnimation<PageController, double>(notifier: _pageController, transformer: (controller) {
return fractionalIndex.clamp(0, 1).toDouble();
});
_rightAnimation = DerivedAnimation<PageController, double>(notifier: _pageController, transformer: (controller) {
final lastIndex = widget.images.length - 1;
return (lastIndex - fractionalIndex.clamp(lastIndex - 1, lastIndex)).toDouble();
});
_pageController.addListener(() {
if (fractionalIndex == index) {
// reset invisible controllers
for (int i = 0; i < index; i++) {
_transformationControllers[i].value.setIdentity();
}
for (int i = index + 1; i < _transformationControllers.length; i++) {
_transformationControllers[i].value.setIdentity();
}
}
});
_processTransformationControllers();
}

@override
void didUpdateWidget(covariant GalleryNavigator oldWidget) {
super.didUpdateWidget(oldWidget);
_processTransformationControllers();
}

void _processTransformationControllers() {
int diff = widget.images.length - _transformationControllers.length;
for (; diff > 0; diff--) {
_transformationControllers.add(TransformationController());
}
for (; diff < 0; diff++) {
final controller = _transformationControllers.removeLast();
controller.dispose();
}
}

num get fractionalIndex => _pageController.hasClients && _pageController.page != null
? _pageController.page!
: _pageController.initialPage;

int get index => fractionalIndex.round();

bool get pagingDisabled =>
_pointerCount > 1 || _transformationControllers[index].value.getMaxScaleOnAxis() > 1;

void goToPreviousImage() {
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutCubicEmphasized,
);
}

void goToNextImage() {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutCubicEmphasized,
);
}

@override
void dispose() {
_pageController.dispose();
for (final controller in _transformationControllers) {
controller.dispose();
}
super.dispose();
}

@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
// Workaround for scale and drag interference. See:
// https://github.com/flutter/flutter/issues/137189
// https://github.com/flutter/flutter/issues/68594
// https://github.com/flutter/flutter/issues/65006
child: Listener(
behavior: HitTestBehavior.deferToChild,
onPointerDown: (event) {
setState(() => _pointerCount++ );
},
onPointerUp: (event) {
setState(() => _pointerCount-- );
},
child: PageView.custom(
controller: _pageController,
scrollDirection: Axis.horizontal,
// required to prevent panning when the user actually wants to pinch zoom
// paging is also disabled when the image is scaled/zoomed in
physics: pagingDisabled
? const NeverScrollableScrollPhysics()
: const PageScrollPhysics(),
onPageChanged: (value) {
setState(() {
// used to trigger a rebuild because pagingDisabled can change
// when the index changes (e.g. on arrow tap)
});
},
childrenDelegate: SliverChildBuilderDelegate((context, index) {
return InteractiveViewer(
transformationController: _transformationControllers[index],
maxScale: 3,
child: FittedBox(
fit: BoxFit.contain,
child: Hero(
tag: widget.imagesKeys[index],
child: Image.asset(
widget.images[index],
errorBuilder: (context, _, __) {
return Image.asset(
'assets/images/placeholder_image.png',
);
},
),
),
),
);
},
childCount: widget.images.length,
// This will dispose images/widgets that are out of view
// Necessary so only get Hero animations for visible images
addAutomaticKeepAlives: false,
),
),
),
),
Positioned(
left: 0,
child: SafeArea(
child: FadeTransition(
opacity: _leftAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(-1.0, 0.0),
end: Offset.zero,
).animate(_leftAnimation),
child: IconButton(
padding: EdgeInsets.zero,
onPressed: goToPreviousImage,
color: Theme.of(context).colorScheme.onPrimary,
icon: Icon(
Icons.navigate_before_rounded,
size: 40.0,
shadows: [
Shadow(color: Theme.of(context).colorScheme.shadow, blurRadius: 25),
],
),
),
),
),
),
),
Positioned(
right: 0,
child: SafeArea(
child: FadeTransition(
opacity: _rightAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(_rightAnimation),
child: IconButton(
padding: EdgeInsets.zero,
onPressed: goToNextImage,
color: Theme.of(context).colorScheme.onPrimary,
icon: Icon(
Icons.navigate_next_rounded,
size: 40.0,
shadows: [
Shadow(color: Theme.of(context).colorScheme.shadow, blurRadius: 25),
],
),
),
),
),
),
),
],
);
}
}
14 changes: 9 additions & 5 deletions lib/widgets/hero_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class HeroViewer extends StatefulWidget {

final Widget child;

final Object? tag;

/// A custom builder to add additional widgets around or beneath the hero child.
/// The child given in this function will already be wrapped inside the respective hero widget.
/// Note: the returned widget tree needs to contain the given child widget at some point in the hierarchy.
Expand Down Expand Up @@ -67,7 +69,8 @@ class HeroViewer extends StatefulWidget {
this.closeOn = InteractionTrigger.tap,
this.routeTransitionsBuilder = HeroViewer.defaultRouteTransitionsBuilder,
this.routeTransitionDuration = const Duration(milliseconds: 300),
Key? key,
this.tag,
Key? key,
}) : super(key: key);

@override
Expand All @@ -89,7 +92,7 @@ class _HeroViewerState extends State<HeroViewer> {
onDoubleTap: widget.openOn == InteractionTrigger.doubleTap ? showViewer : null,
onLongPress: widget.openOn == InteractionTrigger.longPress ? showViewer : null,
child: Hero(
tag: _uniqueTag,
tag: widget.tag ?? _uniqueTag,
child: widget.child
),
);
Expand All @@ -101,9 +104,9 @@ class _HeroViewerState extends State<HeroViewer> {
HeroViewerRoute(
child: _HeroViewerPage(
builder: widget.pageBuilder,
tag: _uniqueTag,
tag: widget.tag ?? _uniqueTag,
trigger: widget.closeOn,
child: widget.child
child: widget.child,
),
transitionBuilder: widget.routeTransitionsBuilder,
transitionDuration: widget.routeTransitionDuration,
Expand Down Expand Up @@ -144,7 +147,8 @@ class HeroViewerRoute<T> extends PageRoute<T> {
required this.transitionBuilder,
this.transitionDuration = const Duration(milliseconds: 300),
this.maintainState = true,
this.opaque = true,
// This attribute is false to avoid rebuild the previous route/reaload images. See: https://github.com/flutter/flutter/issues/124382
this.opaque = false,
this.barrierColor,
this.barrierDismissible = true,
this.barrierLabel
Expand Down
Loading

0 comments on commit fd5c823

Please sign in to comment.