feat: zoom widget de imagem
This commit is contained in:
@@ -33,6 +33,57 @@ class ImageViewer extends StatefulWidget {
|
|||||||
class ImageViewerController extends State<ImageViewer> {
|
class ImageViewerController extends State<ImageViewer> {
|
||||||
final FocusNode focusNode = FocusNode();
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -72,6 +123,8 @@ class ImageViewerController extends State<ImageViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> prevImage() async {
|
Future<void> prevImage() async {
|
||||||
|
if (!canGoBack) return;
|
||||||
|
resetZooms();
|
||||||
await pageController.previousPage(
|
await pageController.previousPage(
|
||||||
duration: FluffyThemes.animationDuration,
|
duration: FluffyThemes.animationDuration,
|
||||||
curve: FluffyThemes.animationCurve,
|
curve: FluffyThemes.animationCurve,
|
||||||
@@ -81,6 +134,8 @@ class ImageViewerController extends State<ImageViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextImage() async {
|
Future<void> nextImage() async {
|
||||||
|
if (!canGoNext) return;
|
||||||
|
resetZooms();
|
||||||
await pageController.nextPage(
|
await pageController.nextPage(
|
||||||
duration: FluffyThemes.animationDuration,
|
duration: FluffyThemes.animationDuration,
|
||||||
curve: FluffyThemes.animationCurve,
|
curve: FluffyThemes.animationCurve,
|
||||||
@@ -112,23 +167,47 @@ class ImageViewerController extends State<ImageViewer> {
|
|||||||
|
|
||||||
static const maxScaleFactor = 1.5;
|
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
|
/// Go back if user swiped it away
|
||||||
void onInteractionEnds(ScaleEndDetails endDetails) {
|
void onInteractionEnds(ScaleEndDetails endDetails) {
|
||||||
if (PlatformInfos.usesTouchscreen == false) {
|
if (PlatformInfos.usesTouchscreen == false) {
|
||||||
|
if (isZoomed) return;
|
||||||
if (endDetails.velocity.pixelsPerSecond.dy >
|
if (endDetails.velocity.pixelsPerSecond.dy >
|
||||||
MediaQuery.sizeOf(context).height * maxScaleFactor) {
|
MediaQuery.sizeOf(context).height * maxScaleFactor) {
|
||||||
Navigator.of(context, rootNavigator: false).pop();
|
closeAction();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
for (final controller in _transformations.values) {
|
||||||
|
controller.removeListener(_onTransformChanged);
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
_transformations.clear();
|
||||||
focusNode.dispose();
|
focusNode.dispose();
|
||||||
pageController.dispose();
|
pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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,
|
foregroundColor: Colors.white,
|
||||||
);
|
);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
onTap: controller.closeAction,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black.withAlpha(128),
|
backgroundColor: Colors.black.withAlpha(128),
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
@@ -34,7 +34,7 @@ class ImageViewerView extends StatelessWidget {
|
|||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
style: iconButtonStyle,
|
style: iconButtonStyle,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: controller.closeAction,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
tooltip: L10n.of(context).close,
|
tooltip: L10n.of(context).close,
|
||||||
),
|
),
|
||||||
@@ -78,9 +78,16 @@ class ImageViewerView extends StatelessWidget {
|
|||||||
KeyboardListener(
|
KeyboardListener(
|
||||||
focusNode: controller.focusNode,
|
focusNode: controller.focusNode,
|
||||||
onKeyEvent: controller.onKeyEvent,
|
onKeyEvent: controller.onKeyEvent,
|
||||||
|
child: Listener(
|
||||||
|
onPointerDown: (_) => controller.onPointerDown(),
|
||||||
|
onPointerUp: (_) => controller.onPointerUp(),
|
||||||
|
onPointerCancel: (_) => controller.onPointerUp(),
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
controller: controller.pageController,
|
controller: controller.pageController,
|
||||||
|
physics: controller.canSwipeToNextImage
|
||||||
|
? const PageScrollPhysics()
|
||||||
|
: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: controller.allEvents.length,
|
itemCount: controller.allEvents.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final event = controller.allEvents[i];
|
final event = controller.allEvents[i];
|
||||||
@@ -100,6 +107,8 @@ class ImageViewerView extends StatelessWidget {
|
|||||||
case MessageTypes.Sticker:
|
case MessageTypes.Sticker:
|
||||||
default:
|
default:
|
||||||
return InteractiveViewer(
|
return InteractiveViewer(
|
||||||
|
transformationController: controller
|
||||||
|
.transformationControllerFor(event),
|
||||||
minScale: 1.0,
|
minScale: 1.0,
|
||||||
maxScale: 10.0,
|
maxScale: 10.0,
|
||||||
onInteractionEnd: controller.onInteractionEnds,
|
onInteractionEnd: controller.onInteractionEnds,
|
||||||
@@ -124,6 +133,7 @@ class ImageViewerView extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (hovered)
|
if (hovered)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
|
|||||||
@@ -13,14 +13,27 @@ class MxcImageViewer extends StatelessWidget {
|
|||||||
|
|
||||||
const MxcImageViewer(this.mxContent, {super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final iconButtonStyle = IconButton.styleFrom(
|
final iconButtonStyle = IconButton.styleFrom(
|
||||||
backgroundColor: Colors.black.withAlpha(200),
|
backgroundColor: Colors.black.withAlpha(200),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
);
|
);
|
||||||
return GestureDetector(
|
return PopScope<Object?>(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
// 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(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black.withAlpha(128),
|
backgroundColor: Colors.black.withAlpha(128),
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
@@ -29,7 +42,7 @@ class MxcImageViewer extends StatelessWidget {
|
|||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
style: iconButtonStyle,
|
style: iconButtonStyle,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: () => _close(context),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
tooltip: L10n.of(context).close,
|
tooltip: L10n.of(context).close,
|
||||||
),
|
),
|
||||||
@@ -41,7 +54,7 @@ class MxcImageViewer extends StatelessWidget {
|
|||||||
onInteractionEnd: (endDetails) {
|
onInteractionEnd: (endDetails) {
|
||||||
if (endDetails.velocity.pixelsPerSecond.dy >
|
if (endDetails.velocity.pixelsPerSecond.dy >
|
||||||
MediaQuery.sizeOf(context).height * 1.5) {
|
MediaQuery.sizeOf(context).height * 1.5) {
|
||||||
Navigator.of(context, rootNavigator: false).pop();
|
_close(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -59,6 +72,7 @@ class MxcImageViewer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user