feat: zoom widget de imagem

This commit is contained in:
2026-09-25 14:41:13 -03:00
parent 9382a1f946
commit a31a88f87a
3 changed files with 179 additions and 76 deletions

View File

@@ -33,6 +33,57 @@ class ImageViewer extends StatefulWidget {
class ImageViewerController extends State<ImageViewer> {
final FocusNode focusNode = FocusNode();
/// One [TransformationController] per image, so the zoom of one page never
/// leaks into its neighbours.
final Map<String, TransformationController> _transformations = {};
int _pointerCount = 0;
bool _isZoomed = false;
/// True while an image is zoomed in, so panning moves the image instead of
/// switching to the next one.
bool get isZoomed => _isZoomed;
/// The [PageView] must not compete for the gesture while pinching or while
/// an image is zoomed, otherwise the swipe recognizer wins the gesture arena
/// and pinch-to-zoom never happens.
bool get canSwipeToNextImage => _pointerCount < 2 && !_isZoomed;
TransformationController transformationControllerFor(Event event) =>
_transformations.putIfAbsent(event.eventId, () {
final controller = TransformationController();
controller.addListener(_onTransformChanged);
return controller;
});
void onPointerDown() {
if (!mounted) return;
setState(() => _pointerCount++);
}
void onPointerUp() {
if (!mounted || _pointerCount == 0) return;
setState(() => _pointerCount--);
}
void _onTransformChanged() {
if (!mounted) return;
final zoomed = _transformations.values.any(
(controller) => controller.value.getMaxScaleOnAxis() > 1.01,
);
if (zoomed == _isZoomed) return;
setState(() => _isZoomed = zoomed);
}
/// Resets the zoom of every image, e.g. after moving to another one.
void resetZooms() {
for (final controller in _transformations.values) {
if (!controller.value.isIdentity()) {
controller.value = Matrix4.identity();
}
}
}
@override
void initState() {
super.initState();
@@ -72,6 +123,8 @@ class ImageViewerController extends State<ImageViewer> {
}
Future<void> prevImage() async {
if (!canGoBack) return;
resetZooms();
await pageController.previousPage(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
@@ -81,6 +134,8 @@ class ImageViewerController extends State<ImageViewer> {
}
Future<void> nextImage() async {
if (!canGoNext) return;
resetZooms();
await pageController.nextPage(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
@@ -112,23 +167,47 @@ class ImageViewerController extends State<ImageViewer> {
static const maxScaleFactor = 1.5;
/// Closes the viewer.
///
/// Always targets the root navigator, because the viewer is opened with
/// `showDialog` on the root navigator while the chat room lives in a nested
/// navigator of the go_router shell. Using a relative navigator here would
/// either pop nothing or pop the wrong route.
void closeAction() => Navigator.of(context, rootNavigator: true).pop();
/// Go back if user swiped it away
void onInteractionEnds(ScaleEndDetails endDetails) {
if (PlatformInfos.usesTouchscreen == false) {
if (isZoomed) return;
if (endDetails.velocity.pixelsPerSecond.dy >
MediaQuery.sizeOf(context).height * maxScaleFactor) {
Navigator.of(context, rootNavigator: false).pop();
closeAction();
}
}
}
@override
void dispose() {
for (final controller in _transformations.values) {
controller.removeListener(_onTransformChanged);
controller.dispose();
}
_transformations.clear();
focusNode.dispose();
pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ImageViewerView(this);
Widget build(BuildContext context) => PopScope<Object?>(
// Claim the system back button (and predictive back gesture) for the
// viewer. Without this, the request can reach go_router instead, which
// pops the chat room out of the shell and leaves the image on screen.
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
closeAction();
},
child: ImageViewerView(this),
);
}