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),
);
}

View File

@@ -25,7 +25,7 @@ class ImageViewerView extends StatelessWidget {
foregroundColor: Colors.white,
);
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
onTap: controller.closeAction,
child: Scaffold(
backgroundColor: Colors.black.withAlpha(128),
extendBodyBehindAppBar: true,
@@ -34,7 +34,7 @@ class ImageViewerView extends StatelessWidget {
leading: IconButton(
style: iconButtonStyle,
icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop,
onPressed: controller.closeAction,
color: Colors.white,
tooltip: L10n.of(context).close,
),
@@ -78,50 +78,60 @@ class ImageViewerView extends StatelessWidget {
KeyboardListener(
focusNode: controller.focusNode,
onKeyEvent: controller.onKeyEvent,
child: PageView.builder(
scrollDirection: Axis.vertical,
controller: controller.pageController,
itemCount: controller.allEvents.length,
itemBuilder: (context, i) {
final event = controller.allEvents[i];
switch (event.messageType) {
case MessageTypes.Video:
return Padding(
padding: const EdgeInsets.only(top: 52.0),
child: Center(
child: GestureDetector(
// Ignore taps to not go back here:
onTap: () {},
child: EventVideoPlayer(event),
),
),
);
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: Listener(
onPointerDown: (_) => controller.onPointerDown(),
onPointerUp: (_) => controller.onPointerUp(),
onPointerCancel: (_) => controller.onPointerUp(),
child: PageView.builder(
scrollDirection: Axis.vertical,
controller: controller.pageController,
physics: controller.canSwipeToNextImage
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics(),
itemCount: controller.allEvents.length,
itemBuilder: (context, i) {
final event = controller.allEvents[i];
switch (event.messageType) {
case MessageTypes.Video:
return Padding(
padding: const EdgeInsets.only(top: 52.0),
child: Center(
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,
child: EventVideoPlayer(event),
),
),
);
case MessageTypes.Image:
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)

View File

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