Merge 7a54c14e21 into 9c28e5ed6e
This commit is contained in:
commit
003faed1a8
4 changed files with 174 additions and 6 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
108
lib/pages/image_viewer/zoomable_image.dart
Normal file
108
lib/pages/image_viewer/zoomable_image.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue