feat: accept edge swipes across the whole margin beside the text column

This commit is contained in:
randogoth 2026-10-11 11:53:27 +03:00
parent 56f3d959fa
commit 6f9c0e587a
3 changed files with 70 additions and 9 deletions

View file

@ -97,6 +97,11 @@ class _ReaderScreenState extends State<ReaderScreen> {
dark: Theme.of(context).brightness == Brightness.dark, dark: Theme.of(context).brightness == Brightness.dark,
).background; ).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( return SwipeNavigator(
canGoBack: browser.canGoBack, canGoBack: browser.canGoBack,
canGoForward: browser.canGoForward, canGoForward: browser.canGoForward,
@ -105,6 +110,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
backTitle: browser.backPage.value?.title, backTitle: browser.backPage.value?.title,
forwardTitle: browser.forwardPage.value?.title, forwardTitle: browser.forwardPage.value?.title,
background: peekBackground, background: peekBackground,
contentWidth: page == null ? null : contentWidth,
onBack: browser.back, onBack: browser.back,
onForward: browser.forward, onForward: browser.forward,
child: Stack( child: Stack(

View file

@ -21,6 +21,7 @@ class SwipeNavigator extends StatefulWidget {
this.forwardView, this.forwardView,
this.backTitle, this.backTitle,
this.forwardTitle, this.forwardTitle,
this.contentWidth,
super.key, super.key,
}); });
@ -35,6 +36,11 @@ class SwipeNavigator extends StatefulWidget {
final String? backTitle; final String? backTitle;
final String? forwardTitle; 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 Color background;
final VoidCallback onBack; final VoidCallback onBack;
final VoidCallback onForward; final VoidCallback onForward;
@ -73,10 +79,16 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
bool get _settledAnimating => _settling != null && _settle.isAnimating; bool get _settledAnimating => _settling != null && _settle.isAnimating;
/// How close to a screen edge a drag must begin to navigate; horizontal /// Base zone at each screen edge that always swipes; horizontal drags
/// drags elsewhere belong to text selection. /// elsewhere belong to text selection.
static const _edgeZone = 40.0; 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) { void _onDragStart(DragStartDetails details) {
if (_settledAnimating) return; if (_settledAnimating) return;
setState(() => _dragging = true); setState(() => _dragging = true);
@ -172,23 +184,23 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
offset: Offset(dx, 0), offset: Offset(dx, 0),
child: widget.child, child: widget.child,
), ),
// Only horizontal drags that begin at a screen edge navigate; // Horizontal drags in the strips navigate; elsewhere they select
// the strips are translucent, so taps, vertical scrolling and // text. The strips are translucent, so taps, vertical scrolling
// text selection still pass through everywhere. // and mid-page selection still pass through everywhere.
_edgeStrip(fromLeft: true), _edgeStrip(fromLeft: true, width: _stripWidth(_width)),
_edgeStrip(fromLeft: false), _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, left: fromLeft ? 0 : null,
right: fromLeft ? null : 0, right: fromLeft ? null : 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
width: _edgeZone, width: width,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onHorizontalDragStart: _onDragStart, onHorizontalDragStart: _onDragStart,

View file

@ -20,8 +20,10 @@ SwipeNavigator _navigator({
required VoidCallback onForward, required VoidCallback onForward,
bool canGoBack = true, bool canGoBack = true,
bool canGoForward = true, bool canGoForward = true,
double? contentWidth,
}) => }) =>
SwipeNavigator( SwipeNavigator(
contentWidth: contentWidth,
canGoBack: canGoBack, canGoBack: canGoBack,
canGoForward: canGoForward, canGoForward: canGoForward,
backTitle: 'Previous page', backTitle: 'Previous page',
@ -101,6 +103,47 @@ void main() {
expect(find.text('Previous page'), findsNothing); 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', testWidgets('directions without pages rubber-band and never commit',
(tester) async { (tester) async {
var back = 0; var back = 0;