From 6f9c0e587ae18ccc0fb1d2be8246ad7e63a5abbf Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 11:53:27 +0300 Subject: [PATCH] feat: accept edge swipes across the whole margin beside the text column --- .../presentation/reader/reader_screen.dart | 6 +++ .../presentation/reader/swipe_navigator.dart | 30 +++++++++---- app/test/swipe_navigator_test.dart | 43 +++++++++++++++++++ 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index dfb2a59..92bca32 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -97,6 +97,11 @@ class _ReaderScreenState extends State { dark: Theme.of(context).brightness == Brightness.dark, ).background; + // The 40em column plus its padding: swipes are accepted + // anywhere in the margin beside it. + final contentWidth = + 42 * 17.0 * deps.settings.fontScale.value; + return SwipeNavigator( canGoBack: browser.canGoBack, canGoForward: browser.canGoForward, @@ -105,6 +110,7 @@ class _ReaderScreenState extends State { backTitle: browser.backPage.value?.title, forwardTitle: browser.forwardPage.value?.title, background: peekBackground, + contentWidth: page == null ? null : contentWidth, onBack: browser.back, onForward: browser.forward, child: Stack( diff --git a/app/lib/presentation/reader/swipe_navigator.dart b/app/lib/presentation/reader/swipe_navigator.dart index 3a0fcdd..90b830d 100644 --- a/app/lib/presentation/reader/swipe_navigator.dart +++ b/app/lib/presentation/reader/swipe_navigator.dart @@ -21,6 +21,7 @@ class SwipeNavigator extends StatefulWidget { this.forwardView, this.backTitle, this.forwardTitle, + this.contentWidth, super.key, }); @@ -35,6 +36,11 @@ class SwipeNavigator extends StatefulWidget { final String? backTitle; final String? forwardTitle; + /// Width of the content column; swipes are accepted anywhere in the empty + /// margin beside it, plus a base zone at each screen edge. Null keeps only + /// the base edge zone. + final double? contentWidth; + final Color background; final VoidCallback onBack; final VoidCallback onForward; @@ -73,10 +79,16 @@ class _SwipeNavigatorState extends State bool get _settledAnimating => _settling != null && _settle.isAnimating; - /// How close to a screen edge a drag must begin to navigate; horizontal - /// drags elsewhere belong to text selection. + /// Base zone at each screen edge that always swipes; horizontal drags + /// elsewhere belong to text selection. static const _edgeZone = 40.0; + /// The swipe zone: the whole margin beside the content column, with the + /// base zone as the floor. + double _stripWidth(double width) => widget.contentWidth == null + ? _edgeZone + : math.max(_edgeZone, (width - widget.contentWidth!) / 2); + void _onDragStart(DragStartDetails details) { if (_settledAnimating) return; setState(() => _dragging = true); @@ -172,23 +184,23 @@ class _SwipeNavigatorState extends State offset: Offset(dx, 0), child: widget.child, ), - // Only horizontal drags that begin at a screen edge navigate; - // the strips are translucent, so taps, vertical scrolling and - // text selection still pass through everywhere. - _edgeStrip(fromLeft: true), - _edgeStrip(fromLeft: false), + // Horizontal drags in the strips navigate; elsewhere they select + // text. The strips are translucent, so taps, vertical scrolling + // and mid-page selection still pass through everywhere. + _edgeStrip(fromLeft: true, width: _stripWidth(_width)), + _edgeStrip(fromLeft: false, width: _stripWidth(_width)), ], ); }, ); } - Widget _edgeStrip({required bool fromLeft}) => Positioned( + Widget _edgeStrip({required bool fromLeft, required double width}) => Positioned( left: fromLeft ? 0 : null, right: fromLeft ? null : 0, top: 0, bottom: 0, - width: _edgeZone, + width: width, child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragStart: _onDragStart, diff --git a/app/test/swipe_navigator_test.dart b/app/test/swipe_navigator_test.dart index 17cc5cc..fa6c1a2 100644 --- a/app/test/swipe_navigator_test.dart +++ b/app/test/swipe_navigator_test.dart @@ -20,8 +20,10 @@ SwipeNavigator _navigator({ required VoidCallback onForward, bool canGoBack = true, bool canGoForward = true, + double? contentWidth, }) => SwipeNavigator( + contentWidth: contentWidth, canGoBack: canGoBack, canGoForward: canGoForward, backTitle: 'Previous page', @@ -101,6 +103,47 @@ void main() { expect(find.text('Previous page'), findsNothing); }); + testWidgets('swipes anywhere in the margin beside the column', + (tester) async { + var back = 0; + await tester.pumpWidget(_wrap(_navigator( + onBack: () => back++, + onForward: () {}, + // A 40em column leaves 200px margins on an 800px surface. + contentWidth: 400, + ))); + + final center = tester.getCenter(find.byType(SwipeNavigator)); + // Deep inside the left margin, far past the base edge zone. + final gesture = await tester.startGesture(Offset(180, center.dy)); + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + expect(find.text('Previous page'), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + expect(back, 1); + }); + + testWidgets('drags over the content column still belong to selection', + (tester) async { + var back = 0; + await tester.pumpWidget(_wrap(_navigator( + onBack: () => back++, + onForward: () {}, + contentWidth: 400, + ))); + + final center = tester.getCenter(find.byType(SwipeNavigator)); + // Inside the column: no swipe, nothing peeks. + final gesture = await tester.startGesture(Offset(400, center.dy)); + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + expect(find.text('Previous page'), findsNothing); + await gesture.up(); + await tester.pumpAndSettle(); + expect(back, 0); + }); + testWidgets('directions without pages rubber-band and never commit', (tester) async { var back = 0;