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;
|
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) {
|
void onKeyEvent(KeyEvent event) {
|
||||||
switch (event.logicalKey) {
|
switch (event.logicalKey) {
|
||||||
case LogicalKeyboardKey.arrowUp:
|
case LogicalKeyboardKey.arrowUp:
|
||||||
if (canGoBack) prevImage();
|
if (canGoBack && !isZoomed) prevImage();
|
||||||
break;
|
break;
|
||||||
case LogicalKeyboardKey.arrowDown:
|
case LogicalKeyboardKey.arrowDown:
|
||||||
if (canGoNext) nextImage();
|
if (canGoNext && !isZoomed) nextImage();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:fluffychat/utils/platform_infos.dart';
|
||||||
import 'package:fluffychat/widgets/hover_builder.dart';
|
import 'package:fluffychat/widgets/hover_builder.dart';
|
||||||
import 'package:fluffychat/widgets/mxc_image.dart';
|
import 'package:fluffychat/widgets/mxc_image.dart';
|
||||||
import 'image_viewer.dart';
|
import 'image_viewer.dart';
|
||||||
|
import 'zoomable_image.dart';
|
||||||
|
|
||||||
class ImageViewerView extends StatelessWidget {
|
class ImageViewerView extends StatelessWidget {
|
||||||
final ImageViewerController controller;
|
final ImageViewerController controller;
|
||||||
|
|
@ -74,8 +75,13 @@ class ImageViewerView extends StatelessWidget {
|
||||||
KeyboardListener(
|
KeyboardListener(
|
||||||
focusNode: controller.focusNode,
|
focusNode: controller.focusNode,
|
||||||
onKeyEvent: controller.onKeyEvent,
|
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(
|
child: PageView.builder(
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
controller: controller.pageController,
|
controller: controller.pageController,
|
||||||
itemCount: controller.allEvents.length,
|
itemCount: controller.allEvents.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
|
|
@ -95,10 +101,15 @@ class ImageViewerView extends StatelessWidget {
|
||||||
case MessageTypes.Image:
|
case MessageTypes.Image:
|
||||||
case MessageTypes.Sticker:
|
case MessageTypes.Sticker:
|
||||||
default:
|
default:
|
||||||
return InteractiveViewer(
|
// The ZoomableImage handles all gestures:
|
||||||
minScale: 1.0,
|
// - Double tap to zoom
|
||||||
maxScale: 10.0,
|
// - Pinch to zoom
|
||||||
|
// - Drag to scroll (drives the PageView manually)
|
||||||
|
return ZoomableImage(
|
||||||
|
onZoomChanged: controller.setZoomed,
|
||||||
onInteractionEnd: controller.onInteractionEnds,
|
onInteractionEnd: controller.onInteractionEnds,
|
||||||
|
onDriveScroll: controller.handleDriveScroll,
|
||||||
|
onDriveScrollEnd: controller.handleDriveScrollEnd,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: event.eventId,
|
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.
|
# of modifying this function.
|
||||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
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_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||||
endfunction()
|
endfunction()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue