This commit is contained in:
Gibbz 2026-03-13 08:39:18 +00:00 committed by GitHub
commit 003faed1a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 174 additions and 6 deletions

View file

@ -56,13 +56,62 @@ class ImageViewerController extends State<ImageViewer> {
late final List<Event> allEvents;
bool isZoomed = false;
void setZoomed(bool value) {
if (isZoomed == value) return;
setState(() {
isZoomed = value;
});
}
void handleDriveScroll(double delta) {
if (pageController.positions.isEmpty) return;
final maxScrollExtent = pageController.position.maxScrollExtent;
final minScrollExtent = pageController.position.minScrollExtent;
final newOffset = (pageController.offset - delta).clamp(minScrollExtent, maxScrollExtent);
pageController.jumpTo(newOffset);
}
void handleDriveScrollEnd(ScaleEndDetails details) {
if (pageController.positions.isEmpty) return;
// Determine the target page based on velocity and current offset
final velocity = details.velocity.pixelsPerSecond.dy;
final currentOffset = pageController.offset;
final screenHeight = MediaQuery.sizeOf(context).height;
// Default to current page
int targetDisplayIndex = (currentOffset / screenHeight).round();
// Adjust target page based on fling velocity
if (velocity < -500) {
// Swiped up (Next image)
targetDisplayIndex = (currentOffset / screenHeight).ceil();
} else if (velocity > 500) {
// Swiped down (Prev image)
targetDisplayIndex = (currentOffset / screenHeight).floor();
}
// Ensure bounds
if (targetDisplayIndex < 0) targetDisplayIndex = 0;
if (targetDisplayIndex >= allEvents.length) targetDisplayIndex = allEvents.length - 1;
pageController.animateToPage(
targetDisplayIndex,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
);
}
void onKeyEvent(KeyEvent event) {
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowUp:
if (canGoBack) prevImage();
if (canGoBack && !isZoomed) prevImage();
break;
case LogicalKeyboardKey.arrowDown:
if (canGoNext) nextImage();
if (canGoNext && !isZoomed) nextImage();
break;
}
}

View file

@ -8,6 +8,7 @@ import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import 'image_viewer.dart';
import 'zoomable_image.dart';
class ImageViewerView extends StatelessWidget {
final ImageViewerController controller;
@ -74,8 +75,13 @@ class ImageViewerView extends StatelessWidget {
KeyboardListener(
focusNode: controller.focusNode,
onKeyEvent: controller.onKeyEvent,
// We disable the intrinsic scrolling of the PageView because we want
// the [ZoomableImage] to drive the scrolling manually.
// This allows us to seamlessly switch between scrolling (1 finger)
// and zooming (2 fingers) without the Gesture Arena locking us out.
child: PageView.builder(
scrollDirection: Axis.vertical,
physics: const NeverScrollableScrollPhysics(),
controller: controller.pageController,
itemCount: controller.allEvents.length,
itemBuilder: (context, i) {
@ -95,10 +101,15 @@ class ImageViewerView extends StatelessWidget {
case MessageTypes.Image:
case MessageTypes.Sticker:
default:
return InteractiveViewer(
minScale: 1.0,
maxScale: 10.0,
// The ZoomableImage handles all gestures:
// - Double tap to zoom
// - Pinch to zoom
// - Drag to scroll (drives the PageView manually)
return ZoomableImage(
onZoomChanged: controller.setZoomed,
onInteractionEnd: controller.onInteractionEnds,
onDriveScroll: controller.handleDriveScroll,
onDriveScrollEnd: controller.handleDriveScrollEnd,
child: Center(
child: Hero(
tag: event.eventId,

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
class ZoomableImage extends StatefulWidget {
final Widget child;
final Function(bool isZoomed) onZoomChanged;
final Function(ScaleEndDetails)? onInteractionEnd;
final Function(double delta) onDriveScroll;
final Function(ScaleEndDetails details) onDriveScrollEnd;
const ZoomableImage({
super.key,
required this.child,
required this.onZoomChanged,
required this.onDriveScroll,
required this.onDriveScrollEnd,
this.onInteractionEnd,
});
@override
State<ZoomableImage> createState() => _ZoomableImageState();
}
class _ZoomableImageState extends State<ZoomableImage>
with TickerProviderStateMixin {
late final TransformationController _transformationController;
late final AnimationController _animationController;
Animation<Matrix4>? _animation;
TapDownDetails? _doubleTapDetails;
@override
void initState() {
super.initState();
_transformationController = TransformationController();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
)..addListener(() {
_transformationController.value = _animation!.value;
});
}
@override
void dispose() {
_transformationController.dispose();
_animationController.dispose();
super.dispose();
}
void _handleDoubleTap() {
Matrix4 currentMatrix = _transformationController.value;
double currentScale = currentMatrix.getMaxScaleOnAxis();
Matrix4 endMatrix;
if (currentScale > 1.0) {
// Zoom out to 1.0
endMatrix = Matrix4.identity();
widget.onZoomChanged(false); // We are animating to zoomed out
} else {
// Zoom in to 3.0 centered on the tap position
final position = _doubleTapDetails?.localPosition ??
Offset(context.size!.width / 2, context.size!.height / 2);
endMatrix = Matrix4.identity()
..translate(-position.dx * 2, -position.dy * 2)
..scale(3.0);
widget.onZoomChanged(true);
}
_animation = Matrix4Tween(
begin: currentMatrix,
end: endMatrix,
).animate(CurveTween(curve: Curves.easeInOut).animate(_animationController));
_animationController.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _handleDoubleTap,
child: InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 10.0,
onInteractionStart: (_) {
_animationController.stop();
widget.onZoomChanged(true);
},
onInteractionUpdate: (details) {
// If we are fully zoomed out, we drive the scroll
if (_transformationController.value.getMaxScaleOnAxis() <= 1.0 && details.scale == 1.0) {
widget.onDriveScroll(details.focalPointDelta.dy);
}
},
onInteractionEnd: (details) {
if (_transformationController.value.getMaxScaleOnAxis() <= 1.0) {
widget.onZoomChanged(false);
// Snap the scroll
widget.onDriveScrollEnd(details);
}
widget.onInteractionEnd?.call(details);
},
child: widget.child,
),
);
}
}

View file

@ -43,7 +43,7 @@ endif()
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE -Wall)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()