Optimize iOS full-screen back gesture implementation (#643)

* Optimize iOS full-screen back gesture implementation

- Fix #613 and #617

* Fix setting page
This commit is contained in:
RuriNyan
2025-11-29 14:18:44 +08:00
committed by GitHub
parent b3239757a8
commit 7e928d2c9c
15 changed files with 271 additions and 295 deletions

View File

@@ -20,7 +20,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
super.allowSnapshotting = true,
super.barrierDismissible = false,
this.enableIOSGesture = true,
this.iosFullScreenPopGesture = true,
this.preventRebuild = true,
}) {
assert(opaque);
@@ -50,9 +49,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
@override
final bool enableIOSGesture;
@override
final bool iosFullScreenPopGesture;
@override
final bool preventRebuild;
}
@@ -79,8 +75,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
bool get enableIOSGesture;
bool get iosFullScreenPopGesture;
bool get preventRebuild;
Widget? _child;
@@ -140,7 +134,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
gestureWidth: _kBackGestureWidth,
enabledCallback: () => _isPopGestureEnabled<T>(this),
onStartPopGesture: () => _startPopGesture(this),
fullScreen: iosFullScreenPopGesture,
child: child,
)
: child);
@@ -210,32 +203,41 @@ class IOSBackGestureController {
}
class IOSBackGestureDetector extends StatefulWidget {
const IOSBackGestureDetector(
{required this.enabledCallback,
required this.child,
required this.gestureWidth,
required this.onStartPopGesture,
this.fullScreen = false,
super.key});
const IOSBackGestureDetector({
required this.enabledCallback,
required this.child,
required this.gestureWidth,
required this.onStartPopGesture,
super.key,
});
final double gestureWidth;
final bool Function() enabledCallback;
final IOSBackGestureController Function() onStartPopGesture;
final Widget child;
final bool fullScreen;
@override
State<IOSBackGestureDetector> createState() => _IOSBackGestureDetectorState();
}
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
IOSBackGestureController? _backGestureController;
late _BackSwipeRecognizer _recognizer;
late HorizontalDragGestureRecognizer _recognizer;
@override
void initState() {
super.initState();
_recognizer = _BackSwipeRecognizer(
debugOwner: this,
gestureWidth: widget.gestureWidth,
isPointerInHorizontal: _isPointerInHorizontalScrollable,
onStart: _handleDragStart,
onUpdate: _handleDragUpdate,
onEnd: _handleDragEnd,
onCancel: _handleDragCancel,
);
}
@override
void dispose() {
@@ -243,115 +245,208 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
super.dispose();
}
@override
void initState() {
super.initState();
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
}
@override
Widget build(BuildContext context) {
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr
? MediaQuery.of(context).padding.left
: MediaQuery.of(context).padding.right;
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
final Widget gestureListener = widget.fullScreen
? Positioned.fill(
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.translucent,
),
)
: Positioned(
width: dragAreaWidth,
top: 0.0,
bottom: 0.0,
left: Directionality.of(context) == TextDirection.ltr ? 0.0 : null,
right: Directionality.of(context) == TextDirection.rtl ? 0.0 : null,
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.translucent,
),
);
return Stack(
fit: StackFit.passthrough,
children: <Widget>[
widget.child,
gestureListener,
],
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: {
_BackSwipeRecognizer: GestureRecognizerFactoryWithHandlers<_BackSwipeRecognizer>(
() => _recognizer,
(instance) {
instance.gestureWidth = widget.gestureWidth;
},
),
},
child: widget.child,
);
}
void _handlePointerDown(PointerDownEvent event) {
if (!widget.enabledCallback()) return;
if (widget.fullScreen && _isPointerOverHorizontalScrollable(event)) {
return;
}
_recognizer.addPointer(event);
}
void _handleDragCancel() {
assert(mounted);
_backGestureController?.dragEnd(0.0);
_backGestureController = null;
}
double _convertToLogical(double value) {
switch (Directionality.of(context)) {
case TextDirection.rtl:
return -value;
case TextDirection.ltr:
return value;
}
}
void _handleDragEnd(DragEndDetails details) {
assert(mounted);
assert(_backGestureController != null);
_backGestureController!.dragEnd(_convertToLogical(
details.velocity.pixelsPerSecond.dx / context.size!.width));
_backGestureController = null;
}
void _handleDragStart(DragStartDetails details) {
assert(mounted);
assert(_backGestureController == null);
_backGestureController = widget.onStartPopGesture();
}
void _handleDragUpdate(DragUpdateDetails details) {
assert(mounted);
assert(_backGestureController != null);
_backGestureController!.dragUpdate(
_convertToLogical(details.primaryDelta! / context.size!.width));
}
bool _isPointerOverHorizontalScrollable(PointerDownEvent event) {
bool _isPointerInHorizontalScrollable(Offset globalPosition) {
final HitTestResult result = HitTestResult();
WidgetsBinding.instance.hitTest(result, event.position);
WidgetsBinding.instance.hitTest(result, globalPosition);
for (final entry in result.path) {
final target = entry.target;
if (target is RenderViewport) {
if (_isAxisHorizontal(target.axisDirection)) {
if (target.axisDirection == AxisDirection.left ||
target.axisDirection == AxisDirection.right) {
return true;
}
} else if (target is RenderSliver) {
if (_isAxisHorizontal(target.constraints.axisDirection)) {
}
else if (target is RenderSliver) {
if (target.constraints.axisDirection == AxisDirection.left ||
target.constraints.axisDirection == AxisDirection.right) {
return true;
}
}
else if (target.runtimeType.toString() == '_RenderSingleChildViewport') {
try {
final dynamic renderObject = target;
if (renderObject.axis == Axis.horizontal) {
return true;
}
} catch (e) {
// protected
}
}
else if (target is RenderEditable) {
return true;
}
}
return false;
}
bool _isAxisHorizontal(AxisDirection direction) {
return direction == AxisDirection.left || direction == AxisDirection.right;
void _handleDragStart(DragStartDetails details) {
if (!widget.enabledCallback()) return;
if (mounted && _backGestureController == null) {
_backGestureController = widget.onStartPopGesture();
}
}
void _handleDragUpdate(DragUpdateDetails details) {
if (mounted && _backGestureController != null) {
_backGestureController!.dragUpdate(
_convertToLogical(details.primaryDelta! / context.size!.width));
}
}
void _handleDragEnd(DragEndDetails details) {
if (mounted && _backGestureController != null) {
_backGestureController!.dragEnd(_convertToLogical(
details.velocity.pixelsPerSecond.dx / context.size!.width));
_backGestureController = null;
}
}
void _handleDragCancel() {
if (mounted && _backGestureController != null) {
_backGestureController?.dragEnd(0.0);
_backGestureController = null;
}
}
double _convertToLogical(double value) {
switch (Directionality.of(context)) {
case TextDirection.rtl: return -value;
case TextDirection.ltr: return value;
}
}
}
class _BackSwipeRecognizer extends OneSequenceGestureRecognizer {
_BackSwipeRecognizer({
required this.isPointerInHorizontal,
required this.gestureWidth,
required this.onStart,
required this.onUpdate,
required this.onEnd,
required this.onCancel,
super.debugOwner,
});
final bool Function(Offset globalPosition) isPointerInHorizontal;
double gestureWidth;
final ValueSetter<DragStartDetails> onStart;
final ValueSetter<DragUpdateDetails> onUpdate;
final ValueSetter<DragEndDetails> onEnd;
final VoidCallback onCancel;
Offset? _startGlobal;
bool _accepted = false;
bool _startedInHorizontal = false;
bool _startedNearLeftEdge = false;
VelocityTracker? _velocityTracker;
static const double _minDistance = 5.0;
@override
void addPointer(PointerDownEvent event) {
startTrackingPointer(event.pointer);
_startGlobal = event.position;
_accepted = false;
_startedInHorizontal = isPointerInHorizontal(event.position);
_startedNearLeftEdge = event.position.dx <= gestureWidth;
_velocityTracker = VelocityTracker.withKind(event.kind);
_velocityTracker?.addPosition(event.timeStamp, event.position);
}
@override
void handleEvent(PointerEvent event) {
if (event is PointerMoveEvent || event is PointerUpEvent) {
_velocityTracker?.addPosition(event.timeStamp, event.position);
}
if (event is PointerMoveEvent) {
if (_startGlobal == null) return;
final delta = event.position - _startGlobal!;
final dx = delta.dx;
final dy = delta.dy.abs();
if (!_accepted) {
if (delta.distance < _minDistance) return;
final isRight = dx > 0;
final isHorizontal = dx.abs() > dy * 1.5;
final bool eligible = _startedNearLeftEdge || (!_startedInHorizontal);
if (isRight && isHorizontal && eligible) {
_accepted = true;
resolve(GestureDisposition.accepted);
onStart(DragStartDetails(
globalPosition: _startGlobal!,
localPosition: event.localPosition
));
} else {
resolve(GestureDisposition.rejected);
stopTrackingPointer(event.pointer);
_startGlobal = null;
_velocityTracker = null;
}
}
if (_accepted) {
onUpdate(DragUpdateDetails(
globalPosition: event.position,
localPosition: event.localPosition,
primaryDelta: event.delta.dx,
delta: event.delta,
));
}
} else if (event is PointerUpEvent) {
if (_accepted) {
final Velocity velocity = _velocityTracker?.getVelocity() ?? Velocity.zero;
onEnd(DragEndDetails(
velocity: velocity,
primaryVelocity: velocity.pixelsPerSecond.dx
));
}
_reset();
} else if (event is PointerCancelEvent) {
if (_accepted) {
onCancel();
}
_reset();
}
}
void _reset() {
stopTrackingPointer(0);
_accepted = false;
_startGlobal = null;
_startedInHorizontal = false;
_startedNearLeftEdge = false;
_velocityTracker = null;
}
@override
String get debugDescription => 'IOSBackSwipe';
@override
void didStopTrackingLastPointer(int pointer) {}
}
class SlidePageTransitionBuilder extends PageTransitionsBuilder {
@@ -389,4 +484,4 @@ class SlidePageTransitionBuilder extends PageTransitionsBuilder {
),
);
}
}
}

View File

@@ -541,8 +541,7 @@ class PageJumpTarget {
text: attributes?["text"] ?? attributes?["keyword"] ?? "",
sourceKey: sourceKey,
options: List.from(attributes?["options"] ?? []),
),
iosFullScreenGesture: false,
)
);
} else if (page == "category") {
var key = ComicSource.find(sourceKey)!.categoryData!.key;

View File

@@ -14,20 +14,14 @@ extension Navigation on BuildContext {
return Navigator.of(this).canPop();
}
Future<T?> to<T>(Widget Function() builder,
{bool enableIOSGesture = true, bool iosFullScreenGesture = true}) {
Future<T?> to<T>(Widget Function() builder,) {
return Navigator.of(this).push<T>(AppPageRoute(
builder: (context) => builder(),
enableIOSGesture: enableIOSGesture,
iosFullScreenPopGesture: iosFullScreenGesture));
builder: (context) => builder()));
}
Future<void> toReplacement<T>(Widget Function() builder,
{bool enableIOSGesture = true, bool iosFullScreenGesture = true}) {
Future<void> toReplacement<T>(Widget Function() builder) {
return Navigator.of(this).pushReplacement(AppPageRoute(
builder: (context) => builder(),
enableIOSGesture: enableIOSGesture,
iosFullScreenPopGesture: iosFullScreenGesture));
builder: (context) => builder()));
}
double get width => MediaQuery.of(this).size.width;

View File

@@ -153,9 +153,7 @@ class LocalComic with HistoryMixin implements Comic {
),
author: subtitle,
tags: tags,
),
enableIOSGesture: false,
iosFullScreenGesture: false,
)
);
}