Flutter — Interactive with gallery viewer using InteractiveViewer and Matrix4

NgocLan Luu-Thi
8 min readJan 24, 2024

--

When making applications related to reading books/comics or photos, we often see a function called Gallery Viewer. Today I will show an example of customizing a gallery viewer with interactions such as zoom/pinch, and drag to dismiss like Telegram. In this article, I will take advantage of InteractiveViewer and Matrix4 combined with Translate widget.

Photo by Klaudia Piaskowska on Unsplash

In the beginning, I created a simple project including 2 pages, HomePage with CarouselSlider and navigated to PreviewImageGallery.

Started project with CarouselSlider

Taking the Telegram app as a benchmark, I will analyze the animations in its preview gallery and implement them step by step, the interactions I can find include:

  • Pinch to zoom in/out
  • Double-tap to zoom in/out
  • Drag vertically to pop the page
  • Add animation to page when dragging vertically to pop (has transparent page’s background)
  • Add Hero animate image when entering to page

Let’s get started!

  1. Adding a pinch to zoom in/out action

First I used InteractiveViewer to add pinching and panning around the image. With InteractiveViewerwidget we can customize bounds, enable scale, Margin boundaries, interaction callbacks, TransformController, etc.

Some of the attributes can customize when using InteractiveViewer
InteractiveViewer(
child: Hero(
tag: 'img_${widget.imageUrls.indexOf(e)}',
child: Image.network(
e,
width: double.infinity,
fit: BoxFit.cover,
),
),
),

With such an initial wrap, we can pinch in/out and pan around the image, however, when used with a CarouselSlider or PageView, the horizontal rotation will conflict with swiping to the next/previous image. So we need to add _enablePageView flags to test the control.

bool _enablePageView = true;

and set scrollPhyics as NeverScrollableScrollPhysics to disable scroll.

CarouselOptions(
initialPage: widget.intialIndex,
viewportFraction: 1.0,
enlargeCenterPage: false,
scrollPhysics: _enablePageView ? null : const NeverScrollableScrollPhysics(),
),

How do I know if an image is zoomed in or zoomed out?

Here I will refer to the widget’s scale state using Matrix4 which is obtained InteractiveViewer through TransformationController. With Matrix4, you can watch this video to learn more about the meaning of the values in the matrix4.

final TransformationController _transformationController = TransformationController();
/// The current scale of the [InteractiveViewer].
double get _scale => _transformationController.value.row0.x;
InteractiveViewer(
transformationController: _transformationController,
maxScale: widget.maxScale,
minScale: widget.minScale,
onInteractionUpdate: (details) {
/// Scale update when finger keep touching and move on widget
/// When scale is 1.0, enable drag to pop action, enable swipe left/right action to change page
/// When scale is not 1.0 (Zoomed), disable drag to pop action,disable swipe left/right action to change page
if (_scale == 1.0) {
_enablePageView = true;
} else {
_enablePageView = false;
}
},
),

...

/// dispose
@override
void dispose() {
_transformationController.dispose();
super.dispose();
}

For Matrix4, the diagonal coordinates (0,0) -> (3,3) will represent scale values along each x, y, and z axis, and in both 3 directions (SF). In this case, we just need to get scale x _transformationController.value.row0.x which is enough because the scale factor in InteractiveViewer along the coordinate axes is the same.

The diagonal axis of the matrix4 represents the scale coefficient along the axes in 3-dimensional space

Alright, I got the first result!

Pinch to zoom in/out, and pan around when the image is zooming in

2. Adding double-tap to zoom in/out

Since InteractiveViewer only supports callbacks for scale-related actions, I’ll use GestureDetector to handle the double-tap action.

First, it is necessary to determine the desired value to be found including:

  • (1) double-tap local position
  • (2) transformation matrix4

In the Matrix4, the region outlined in red identifies the offset translation along the x, y, and z axis.

Transformation Matrix4 with x1, y1, z1 offset and scaled factor is a

To make it easier to visualize, I have two drawings below depicting the desired state when double-tap to zoom:

  • A(x,y): Initiate a double-tap on the local position within the viewport screen.
  • A(x,’y’): After scaling with a factor of 2x, I want to keep the focus position at the double-click position, so I have to move the image scaled 2x by a distance x1, y1 to ensure A(x,y) coincides with A(x ‘, y’) as shown
The relative coordinates of any location after transformation are similar to the scale factor 2x

sketch out the following formula:

double_tap_local_position = TapDownDetails.localPosition
x1 = double_tap_local_position.x - double_tap_local_position.x * scaleX
y1 = double_tap_local_position.y - double_tap_local_position.y * scaleY

Now we have all the information we need to transform the Matrix.

onDoubleTap() {
/// Clone matrix4 current
Matrix4 matrix = _transformationController.value.clone();
/// Get the current value to see if the image is in zoom out or zoom in state
final double currentScale = matrix.row0.x;
/// Suppose the current state is zoom out
double targetScale = widget.minScale;
/// Determines the state after a double tap action exactly
if (currentScale <= widget.minScale) {
targetScale = widget.maxScale;
}
/// Calculate translated of offset x1, y1
final double offSetX =
targetScale == widget.minScale ? 0.0 : -_doubleTapLocalPosition.dx * (targetScale - 1);
final double offSetY =
targetScale == widget.minScale ? 0.0 : -_doubleTapLocalPosition.dy * (targetScale - 1);
/// Matrix4 after translated and scaled
matrix = Matrix4.fromList([
/// scale X
targetScale,
matrix.row1.x,
matrix.row2.x,
matrix.row3.x,
matrix.row0.y,
/// scale Y
targetScale,
matrix.row2.y,
matrix.row3.y,
matrix.row0.z,
matrix.row1.z,
/// scale Z
targetScale,
matrix.row3.z,
/// translate X1
offSetX,
/// translate Y1
offSetY,
matrix.row2.w,
matrix.row3.w
]);
/// Use Matrix4Tween to appropriate for transformation between two matrix
_animation = Matrix4Tween(
begin: _transformationController.value,
end: matrix,
).animate(
CurveTween(curve: Curves.easeOut).animate(_animationController),
);
/// Execute the animation to new state
_animationController.forward(from: 0);
}

Remember to declare and init _animationController

@override
void initState() {
super.initState();
...
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addListener(() {
/// listen matrix4 changed, if not, set as default value
_transformationController.value = _animation?.value ?? Matrix4.identity();
});
}

@override
void dispose() {
_animationController.dispose();
_transformationController.dispose();
super.dispose();
}

Specially, SingleTickerProviderStateMixinto create the AnimationController in a State that only uses a single AnimationController, mix in this class, then pass vsync: this to the animation controller constructor.

If you have multiple AnimationController objects over the lifetime of the State, use a full TickerProviderStateMixin instead.

3. Adding drag vertically to pop page

https://www.smashingmagazine.com/2020/02/design-mobile-apps-one-hand-usage/

I often use one-hand to scroll through the phone to view news and images. Although the larger screen helps display images and more information, it also reduces the effort of moving my finger to press back on the top-left screen, so products that want to aim for One-Handed Use UX Pattern often pay attention to this. And the vertical drag function is an example, I can use my index finger to swipe and return to the previous screen easily.

Now, we have added a new controller animation to translate CarouselSlider vertically. Remember to change SingleTickerProviderStateMixinto TickerProviderStateMixin for multiple AnimationController

///Declaring 
/// For handle drag to pop action
late final AnimationController _dragAnimationController;

/// Drag offset animation controller.
late Animation<Offset> _dragAnimation;
Offset? _dragOffset;
Offset? _previousPosition;
bool _enableDrag = true;

/// Update initState
@override
void initState() {
...
/// drag animate
_dragAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
)..addStatusListener((status) {
_onAnimationEnd(status);
});
_dragAnimation = Tween<Offset>(
begin: Offset.zero,
end: Offset.zero,
).animate(_dragAnimationController);

super.initState();
}

void _onAnimationEnd(AnimationStatus status) {
if (status == AnimationStatus.completed) {
_dragAnimationController.reset();
setState(() {
_dragOffset = null;
_previousPosition = null;
});
}
}

Wrap CarouselSlider with AnimatedBuilder

AnimatedBuilder(
builder: (context, Widget? child) {
Offset finalOffset = _dragOffset ?? const Offset(0.0, 0.0);
if (_dragAnimation.status == AnimationStatus.forward)
finalOffset = _dragAnimation.value;
return Transform.translate(
offset: finalOffset,
child: child,
);
},
animation: _dragAnimation,
child: CarouselSlider(
/// improtant to priority scale gesture when using CarouselSlider
disableGesture: true,
items: widget.imageUrls
.map(
(e) => InteractiveViewer(
minScale: widget.minScale,
maxScale: widget.maxScale,
transformationController: _transformationController,
onInteractionUpdate: (details) {
/// will update _dragOffset value
_onDragUpdate(details);
if (_scale == widget.minScale) {
_enableDrag = true;
_enablePageView = true;
} else {
_enableDrag = false;
_enablePageView = false;
}
setState(() {});
},
onInteractionEnd: (details) {
/// to handle end of drag action to pop page or not
_onOverScrollDragEnd(details);
},
onInteractionStart: (details) {
/// to handle to save start offset
_onDragStart(details);
},
...
),
),

Refer to Telegram, I see the draggable to dismiss just work when the image scale as normal state, so I added _enableDrag to control action.


void _onDragStart(ScaleStartDetails scaleDetails) {
_previousPosition = scaleDetails.focalPoint;
}

void _onDragUpdate(ScaleUpdateDetails scaleUpdateDetails) {
final currentPosition = scaleUpdateDetails.focalPoint;
final previousPosition = _previousPosition ?? currentPosition;

final newY = (_dragOffset?.dy ?? 0.0) + (currentPosition.dy - previousPosition.dy);
_previousPosition = currentPosition;
/// just update _dragOffset when _enableDrag = true
if (_enableDrag) {
setState(() {
_dragOffset = Offset(0, newY);
});
}
}

/// Handles the end of an over-scroll drag event.
///
/// If [scaleEndDetails] is not null, it checks if the drag offset exceeds a certain threshold
/// and if the velocity is fast enough to trigger a pop action. If so, it pops the current route.
void _onOverScrollDragEnd(ScaleEndDetails? scaleEndDetails) {
if (_dragOffset == null) return;
final dragOffset = _dragOffset!;

final screenSize = MediaQuery.of(context).size;

if (scaleEndDetails != null) {
if (dragOffset.dy.abs() >= screenSize.height / 3) {
Navigator.of(context, rootNavigator: true).pop();
return;
}
final velocity = scaleEndDetails.velocity.pixelsPerSecond;
final velocityY = velocity.dy;

/// Make sure the velocity is fast enough to trigger the pop action
/// Prevent mistake zoom in fast and drag => check dragOffset.dy.abs() > thresholdOffsetYToEnablePop
const thresholdOffsetYToEnablePop = 75.0;
const thresholdVelocityYToEnablePop = 200.0;
if (velocityY.abs() > thresholdOffsetYToEnablePop &&
dragOffset.dy.abs() > thresholdVelocityYToEnablePop &&
_enableDrag) {
Navigator.of(context, rootNavigator: true).pop();
return;
}
}

/// Reset position to center of the screen when the drag is canceled.
setState(() {
_dragAnimation = Tween<Offset>(
begin: Offset(0.0, dragOffset.dy),
end: const Offset(0.0, 0.0),
).animate(_dragAnimationController);
_dragOffset = const Offset(0.0, 0.0);
_dragAnimationController.forward();
});
}
Draggable to pop page

4. Adding animation to the page when dragging vertically to pop (has transparent page’s background)

Now I’ll change the white background to black when dragging and give it an opacity based on the offset when dragging vertically.

First, let’s configure a little in the Navigation. In that case opaque: false is the decisive value that we can make the background page transparent.

Navigator.of(context).push(
PageRouteBuilder(
opaque: false, // set to false to make the background page transparent
pageBuilder: (context, animation, _) => PreviewImageGallery(
imageUrls: imageUrls,
initialIndex: imageUrls.indexOf(e),
),
),
);

Now we can set a transparent background in the PreviewImageGallery

If you are using auto_route package, use TransitionRoute and CustomRouteBuilder to override opaque value.

Drag to pop with a transparent background

5. Improving

Above I have explained how to analyze the operation of InteractiveViewer, Matrix4, however, improvements are needed such as when interacting with images, the app bar will be hidden, and consistent opacity background page with drag offset,… if you are interested, take a look at the full version.

Thank you for reading!

--

--