opt image gesture

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-02-07 10:18:17 +08:00
parent 29e7e0e556
commit 946a5a1e47
4 changed files with 93 additions and 152 deletions

View File

@@ -0,0 +1,51 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' show TransformationController;
class ImageHorizontalDragGestureRecognizer
extends HorizontalDragGestureRecognizer {
ImageHorizontalDragGestureRecognizer({
super.debugOwner,
super.supportedDevices,
super.allowedButtonsFilter,
required this.width,
required this.transformationController,
});
Offset? _initialPosition;
final double width;
final TransformationController transformationController;
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
_initialPosition = event.position;
}
bool _isBoundaryAllowed() {
if (_initialPosition == null) {
return true;
}
final scale = transformationController.value.row0[0];
if (scale <= 1.0) {
return true;
}
final double xOffset = transformationController.value.row0[3];
final double boundaryEnd = width * scale;
final int xPos = (boundaryEnd + xOffset).round();
return (boundaryEnd.round() == xPos &&
lastPosition.global.dx > _initialPosition!.dx) ||
(width.round() == xPos &&
lastPosition.global.dx < _initialPosition!.dx);
}
@override
bool hasSufficientGlobalDistanceToAccept(
PointerDeviceKind pointerDeviceKind,
double? deviceTouchSlop,
) {
return globalDistanceMoved.abs() >
computeHitSlop(pointerDeviceKind, gestureSettings) &&
_isBoundaryAllowed();
}
}

View File

@@ -71,7 +71,6 @@ class InteractiveViewer extends StatefulWidget {
this.onPanStart, this.onPanStart,
this.onPanUpdate, this.onPanUpdate,
this.onPanEnd, this.onPanEnd,
this.onReset,
this.isAnimating, this.isAnimating,
required Widget this.child, required Widget this.child,
}) : assert(minScale > 0), }) : assert(minScale > 0),
@@ -121,7 +120,6 @@ class InteractiveViewer extends StatefulWidget {
this.onPanStart, this.onPanStart,
this.onPanUpdate, this.onPanUpdate,
this.onPanEnd, this.onPanEnd,
this.onReset,
this.isAnimating, this.isAnimating,
required InteractiveViewerWidgetBuilder this.builder, required InteractiveViewerWidgetBuilder this.builder,
}) : assert(minScale > 0), }) : assert(minScale > 0),
@@ -143,8 +141,7 @@ class InteractiveViewer extends StatefulWidget {
constrained = false, constrained = false,
child = null; child = null;
final Function? isAnimating; final ValueGetter<bool>? isAnimating;
final VoidCallback? onReset;
final ValueChanged<ScaleStartDetails>? onPanStart; final ValueChanged<ScaleStartDetails>? onPanStart;
final ValueChanged<ScaleUpdateDetails>? onPanUpdate; final ValueChanged<ScaleUpdateDetails>? onPanUpdate;
final ValueChanged<ScaleEndDetails>? onPanEnd; final ValueChanged<ScaleEndDetails>? onPanEnd;
@@ -873,9 +870,6 @@ class _InteractiveViewerState extends State<InteractiveViewer>
// Handle the end of a gesture of _GestureType. All of pan, scale, and rotate // Handle the end of a gesture of _GestureType. All of pan, scale, and rotate
// are handled with GestureDetector's scale gesture. // are handled with GestureDetector's scale gesture.
void _onScaleEnd(ScaleEndDetails details) { void _onScaleEnd(ScaleEndDetails details) {
if (_transformer.value.storage[0] == 1.0) {
widget.onReset?.call();
}
if (widget.isAnimating?.call() == true || if (widget.isAnimating?.call() == true ||
(details.pointerCount < 2 && _transformer.value.storage[0] == 1.0)) { (details.pointerCount < 2 && _transformer.value.storage[0] == 1.0)) {
widget.onPanEnd?.call(details); widget.onPanEnd?.call(details);

View File

@@ -19,18 +19,13 @@ class InteractiveViewerBoundary extends StatefulWidget {
required this.child, required this.child,
required this.boundaryWidth, required this.boundaryWidth,
required this.controller, required this.controller,
this.onScaleChanged,
this.onLeftBoundaryHit,
this.onRightBoundaryHit,
this.onNoBoundaryHit,
required this.maxScale, required this.maxScale,
required this.minScale, required this.minScale,
this.onDismissed, this.onDismissed,
this.onReset,
this.dismissThreshold = 0.2, this.dismissThreshold = 0.2,
this.onInteractionEnd,
}); });
final VoidCallback? onReset;
final double dismissThreshold; final double dismissThreshold;
final VoidCallback? onDismissed; final VoidCallback? onDismissed;
@@ -45,22 +40,12 @@ class InteractiveViewerBoundary extends StatefulWidget {
/// The [TransformationController] for the [InteractiveViewer]. /// The [TransformationController] for the [InteractiveViewer].
final TransformationController controller; final TransformationController controller;
/// Called when the scale changed after an interaction ended.
final ScaleChanged? onScaleChanged;
/// Called when the left boundary has been hit after an interaction ended.
final VoidCallback? onLeftBoundaryHit;
/// Called when the right boundary has been hit after an interaction ended.
final VoidCallback? onRightBoundaryHit;
/// Called when no boundary has been hit after an interaction ended.
final VoidCallback? onNoBoundaryHit;
final double maxScale; final double maxScale;
final double minScale; final double minScale;
final GestureScaleEndCallback? onInteractionEnd;
@override @override
InteractiveViewerBoundaryState createState() => InteractiveViewerBoundaryState createState() =>
InteractiveViewerBoundaryState(); InteractiveViewerBoundaryState();
@@ -69,9 +54,6 @@ class InteractiveViewerBoundary extends StatefulWidget {
class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary> class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TransformationController _controller; late TransformationController _controller;
double? _scale;
late AnimationController _animateController; late AnimationController _animateController;
late Animation<Offset> _slideAnimation; late Animation<Offset> _slideAnimation;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
@@ -179,36 +161,6 @@ class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
} }
} }
void _updateBoundaryDetection() {
final double scale = _controller.value.row0[0];
if (_scale != scale) {
// the scale changed
_scale = scale;
widget.onScaleChanged?.call(scale);
}
if (scale <= 1.01) {
// cant hit any boundaries when the child is not scaled
return;
}
final double xOffset = _controller.value.row0[3];
final double boundaryWidth = widget.boundaryWidth;
final double boundaryEnd = boundaryWidth * scale;
final double xPos = boundaryEnd + xOffset;
if (boundaryEnd.round() == xPos.round()) {
// left boundary hit
widget.onLeftBoundaryHit?.call();
} else if (boundaryWidth.round() == xPos.round()) {
// right boundary hit
widget.onRightBoundaryHit?.call();
} else {
widget.onNoBoundaryHit?.call();
}
}
Widget get content => DecoratedBoxTransition( Widget get content => DecoratedBoxTransition(
decoration: _opacityAnimation, decoration: _opacityAnimation,
child: SlideTransition( child: SlideTransition(
@@ -226,11 +178,10 @@ class InteractiveViewerBoundaryState extends State<InteractiveViewerBoundary>
maxScale: widget.maxScale, maxScale: widget.maxScale,
minScale: widget.minScale, minScale: widget.minScale,
transformationController: _controller, transformationController: _controller,
onInteractionEnd: (_) => _updateBoundaryDetection(),
onPanStart: _handleDragStart, onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate, onPanUpdate: _handleDragUpdate,
onPanEnd: _handleDragEnd, onPanEnd: _handleDragEnd,
onReset: widget.onReset, onInteractionEnd: widget.onInteractionEnd,
isAnimating: () => _animateController.value != 0, isAnimating: () => _animateController.value != 0,
child: content, child: content,
); );

View File

@@ -1,5 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:PiliPlus/common/widgets/flutter/page/page_view.dart';
import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart'; import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/utils/extension/string_ext.dart'; import 'package:PiliPlus/utils/extension/string_ext.dart';
@@ -10,7 +12,7 @@ import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/utils.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_debounce/easy_throttle.dart'; import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart' hide PageView;
import 'package:flutter/services.dart' show HapticFeedback; import 'package:flutter/services.dart' show HapticFeedback;
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -33,7 +35,6 @@ typedef IndexedFocusedWidgetBuilder =
BuildContext context, BuildContext context,
int index, int index,
bool isFocus, bool isFocus,
bool enablePageView,
); );
typedef IndexedTagStringBuilder = String Function(int index); typedef IndexedTagStringBuilder = String Function(int index);
@@ -82,16 +83,14 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
late AnimationController _animationController; late AnimationController _animationController;
Animation<Matrix4>? _animation; Animation<Matrix4>? _animation;
/// `true` when an source is zoomed in and not at the at a horizontal boundary
/// to disable the [PageView].
bool _enablePageView = true;
late Offset _doubleTapLocalPosition; late Offset _doubleTapLocalPosition;
late final RxInt currentIndex = widget.initIndex.obs; late final RxInt currentIndex = widget.initIndex.obs;
late final int _quality = Pref.previewQ; late final int _quality = Pref.previewQ;
late final RxBool _hasScaled = false.obs;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -134,57 +133,8 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
super.dispose(); super.dispose();
} }
/// When the source gets scaled up, the swipe up / down to dismiss gets
/// disabled.
///
/// When the scale resets, the dismiss and the page view swiping gets enabled.
void _onScaleChanged(double scale) { void _onScaleChanged(double scale) {
final bool initialScale = scale <= widget.minScale; _hasScaled.value = scale > 1.0;
if (initialScale) {
if (!_enablePageView) {
setState(() {
_enablePageView = true;
});
}
} else {
if (_enablePageView) {
setState(() {
_enablePageView = false;
});
}
}
}
/// When the left boundary has been hit after scaling up the source, the page
/// view swiping gets enabled if it has a page to swipe to.
void _onLeftBoundaryHit() {
if (!_enablePageView && _pageController.page!.floor() > 0) {
setState(() {
_enablePageView = true;
});
}
}
/// When the right boundary has been hit after scaling up the source, the page
/// view swiping gets enabled if it has a page to swipe to.
void _onRightBoundaryHit() {
if (!_enablePageView &&
_pageController.page!.floor() < widget.sources.length - 1) {
setState(() {
_enablePageView = true;
});
}
}
/// When the source has been scaled up and no horizontal boundary has been hit,
/// the page view swiping gets disabled.
void _onNoBoundaryHit() {
if (_enablePageView) {
setState(() {
_enablePageView = false;
});
}
} }
void _onPlay(String liveUrl) { void _onPlay(String liveUrl) {
@@ -229,32 +179,21 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final width = MediaQuery.widthOf(context);
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
InteractiveViewerBoundary( InteractiveViewerBoundary(
controller: _transformationController, controller: _transformationController,
boundaryWidth: MediaQuery.widthOf(context), boundaryWidth: width,
onScaleChanged: _onScaleChanged,
onLeftBoundaryHit: _onLeftBoundaryHit,
onRightBoundaryHit: _onRightBoundaryHit,
onNoBoundaryHit: _onNoBoundaryHit,
maxScale: widget.maxScale, maxScale: widget.maxScale,
minScale: widget.minScale, minScale: widget.minScale,
onDismissed: Get.back, onDismissed: Get.back,
onReset: () { onInteractionEnd: (_) =>
if (!_enablePageView) { _onScaleChanged(_transformationController.value.row0[0]),
setState(() {
_enablePageView = true;
});
}
},
child: PageView.builder( child: PageView.builder(
onPageChanged: _onPageChanged, onPageChanged: _onPageChanged,
controller: _pageController, controller: _pageController,
physics: _enablePageView
? null
: const NeverScrollableScrollPhysics(),
itemCount: widget.sources.length, itemCount: widget.sources.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final item = widget.sources[index]; final item = widget.sources[index];
@@ -283,38 +222,44 @@ class _InteractiveviewerGalleryState extends State<InteractiveviewerGallery>
context, context,
index, index,
index == currentIndex.value, index == currentIndex.value,
_enablePageView,
) )
: _itemBuilder(index, item), : _itemBuilder(index, item),
); );
}, },
horizontalDragGestureRecognizer:
ImageHorizontalDragGestureRecognizer(
width: width,
transformationController: _transformationController,
),
), ),
), ),
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
child: Container( child: Obx(
padding: () => Container(
MediaQuery.viewPaddingOf(context) + padding:
const EdgeInsets.fromLTRB(12, 8, 20, 8), MediaQuery.viewPaddingOf(context) +
decoration: _enablePageView const EdgeInsets.fromLTRB(12, 8, 20, 8),
? BoxDecoration( decoration: _hasScaled.value
gradient: LinearGradient( ? null
begin: Alignment.topCenter, : BoxDecoration(
end: Alignment.bottomCenter, gradient: LinearGradient(
colors: [ begin: Alignment.topCenter,
Colors.transparent, end: Alignment.bottomCenter,
Colors.black.withValues(alpha: 0.3), colors: [
], Colors.transparent,
Colors.black.withValues(alpha: 0.3),
],
),
), ),
) alignment: Alignment.center,
: null, child: Obx(
alignment: Alignment.center, () => Text(
child: Obx( "${currentIndex.value + 1}/${widget.sources.length}",
() => Text( style: const TextStyle(color: Colors.white),
"${currentIndex.value + 1}/${widget.sources.length}", ),
style: const TextStyle(color: Colors.white),
), ),
), ),
), ),