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> { class ImageViewerController extends State<ImageViewer> {
final FocusNode focusNode = FocusNode(); 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -72,6 +123,8 @@ class ImageViewerController extends State<ImageViewer> {
} }
Future<void> prevImage() async { Future<void> prevImage() async {
if (!canGoBack) return;
resetZooms();
await pageController.previousPage( await pageController.previousPage(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
@@ -81,6 +134,8 @@ class ImageViewerController extends State<ImageViewer> {
} }
Future<void> nextImage() async { Future<void> nextImage() async {
if (!canGoNext) return;
resetZooms();
await pageController.nextPage( await pageController.nextPage(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
@@ -112,23 +167,47 @@ class ImageViewerController extends State<ImageViewer> {
static const maxScaleFactor = 1.5; 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 /// Go back if user swiped it away
void onInteractionEnds(ScaleEndDetails endDetails) { void onInteractionEnds(ScaleEndDetails endDetails) {
if (PlatformInfos.usesTouchscreen == false) { if (PlatformInfos.usesTouchscreen == false) {
if (isZoomed) return;
if (endDetails.velocity.pixelsPerSecond.dy > if (endDetails.velocity.pixelsPerSecond.dy >
MediaQuery.sizeOf(context).height * maxScaleFactor) { MediaQuery.sizeOf(context).height * maxScaleFactor) {
Navigator.of(context, rootNavigator: false).pop(); closeAction();
} }
} }
} }
@override @override
void dispose() { void dispose() {
for (final controller in _transformations.values) {
controller.removeListener(_onTransformChanged);
controller.dispose();
}
_transformations.clear();
focusNode.dispose(); focusNode.dispose();
pageController.dispose(); pageController.dispose();
super.dispose(); super.dispose();
} }
@override @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),
);
} }

View File

@@ -25,7 +25,7 @@ class ImageViewerView extends StatelessWidget {
foregroundColor: Colors.white, foregroundColor: Colors.white,
); );
return GestureDetector( return GestureDetector(
onTap: () => Navigator.of(context).pop(), onTap: controller.closeAction,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.black.withAlpha(128), backgroundColor: Colors.black.withAlpha(128),
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
@@ -34,7 +34,7 @@ class ImageViewerView extends StatelessWidget {
leading: IconButton( leading: IconButton(
style: iconButtonStyle, style: iconButtonStyle,
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop, onPressed: controller.closeAction,
color: Colors.white, color: Colors.white,
tooltip: L10n.of(context).close, tooltip: L10n.of(context).close,
), ),
@@ -78,50 +78,60 @@ class ImageViewerView extends StatelessWidget {
KeyboardListener( KeyboardListener(
focusNode: controller.focusNode, focusNode: controller.focusNode,
onKeyEvent: controller.onKeyEvent, onKeyEvent: controller.onKeyEvent,
child: PageView.builder( child: Listener(
scrollDirection: Axis.vertical, onPointerDown: (_) => controller.onPointerDown(),
controller: controller.pageController, onPointerUp: (_) => controller.onPointerUp(),
itemCount: controller.allEvents.length, onPointerCancel: (_) => controller.onPointerUp(),
itemBuilder: (context, i) { child: PageView.builder(
final event = controller.allEvents[i]; scrollDirection: Axis.vertical,
switch (event.messageType) { controller: controller.pageController,
case MessageTypes.Video: physics: controller.canSwipeToNextImage
return Padding( ? const PageScrollPhysics()
padding: const EdgeInsets.only(top: 52.0), : const NeverScrollableScrollPhysics(),
child: Center( itemCount: controller.allEvents.length,
child: GestureDetector( itemBuilder: (context, i) {
// Ignore taps to not go back here: final event = controller.allEvents[i];
onTap: () {}, switch (event.messageType) {
child: EventVideoPlayer(event), case MessageTypes.Video:
), return Padding(
), padding: const EdgeInsets.only(top: 52.0),
); child: Center(
case MessageTypes.Image:
case MessageTypes.Sticker:
default:
return InteractiveViewer(
minScale: 1.0,
maxScale: 10.0,
onInteractionEnd: controller.onInteractionEnds,
child: Center(
child: Hero(
tag: event.eventId,
child: GestureDetector( child: GestureDetector(
// Ignore taps to not go back here: // Ignore taps to not go back here:
onTap: () {}, onTap: () {},
child: MxcImage( child: EventVideoPlayer(event),
key: ValueKey(event.eventId), ),
event: event, ),
fit: BoxFit.contain, );
isThumbnail: false, case MessageTypes.Image:
animated: true, case MessageTypes.Sticker:
default:
return InteractiveViewer(
transformationController: controller
.transformationControllerFor(event),
minScale: 1.0,
maxScale: 10.0,
onInteractionEnd: controller.onInteractionEnds,
child: Center(
child: Hero(
tag: event.eventId,
child: GestureDetector(
// Ignore taps to not go back here:
onTap: () {},
child: MxcImage(
key: ValueKey(event.eventId),
event: event,
fit: BoxFit.contain,
isThumbnail: false,
animated: true,
),
), ),
), ),
), ),
), );
); }
} },
}, ),
), ),
), ),
if (hovered) if (hovered)

View File

@@ -13,47 +13,61 @@ class MxcImageViewer extends StatelessWidget {
const MxcImageViewer(this.mxContent, {super.key}); const MxcImageViewer(this.mxContent, {super.key});
/// The viewer is opened with `showDialog` on the root navigator, so closing
/// it must always target the root navigator.
static void _close(BuildContext context) =>
Navigator.of(context, rootNavigator: true).pop();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final iconButtonStyle = IconButton.styleFrom( final iconButtonStyle = IconButton.styleFrom(
backgroundColor: Colors.black.withAlpha(200), backgroundColor: Colors.black.withAlpha(200),
foregroundColor: Colors.white, foregroundColor: Colors.white,
); );
return GestureDetector( return PopScope<Object?>(
onTap: () => Navigator.of(context).pop(), // Claim the system back button for the viewer so that it is dismissed
child: Scaffold( // instead of letting the router navigate away underneath it.
backgroundColor: Colors.black.withAlpha(128), canPop: false,
extendBodyBehindAppBar: true, onPopInvokedWithResult: (didPop, _) {
appBar: AppBar( if (didPop) return;
elevation: 0, _close(context);
leading: IconButton( },
style: iconButtonStyle, child: GestureDetector(
icon: const Icon(Icons.close), onTap: () => _close(context),
onPressed: Navigator.of(context).pop, child: Scaffold(
color: Colors.white, backgroundColor: Colors.black.withAlpha(128),
tooltip: L10n.of(context).close, extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
leading: IconButton(
style: iconButtonStyle,
icon: const Icon(Icons.close),
onPressed: () => _close(context),
color: Colors.white,
tooltip: L10n.of(context).close,
),
backgroundColor: Colors.transparent,
), ),
backgroundColor: Colors.transparent, body: InteractiveViewer(
), minScale: 1.0,
body: InteractiveViewer( maxScale: 10.0,
minScale: 1.0, onInteractionEnd: (endDetails) {
maxScale: 10.0, if (endDetails.velocity.pixelsPerSecond.dy >
onInteractionEnd: (endDetails) { MediaQuery.sizeOf(context).height * 1.5) {
if (endDetails.velocity.pixelsPerSecond.dy > _close(context);
MediaQuery.sizeOf(context).height * 1.5) { }
Navigator.of(context, rootNavigator: false).pop(); },
} child: Center(
}, child: GestureDetector(
child: Center( // Ignore taps to not go back here:
child: GestureDetector( onTap: () {},
// Ignore taps to not go back here: child: MxcImage(
onTap: () {}, key: ValueKey(mxContent.toString()),
child: MxcImage( uri: mxContent,
key: ValueKey(mxContent.toString()), fit: BoxFit.contain,
uri: mxContent, isThumbnail: false,
fit: BoxFit.contain, animated: true,
isThumbnail: false, ),
animated: true,
), ),
), ),
), ),