feat: zoom widget de imagem
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user