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,
).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<ReaderScreen> {
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(

View file

@ -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<SwipeNavigator>
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<SwipeNavigator>
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,

View file

@ -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;