fix: optimized swiping and address bar toggle on tap

This commit is contained in:
randogoth 2026-10-12 09:52:28 +03:00
parent ac998b394d
commit 0e2027c9e2
5 changed files with 466 additions and 78 deletions

View file

@ -2,6 +2,8 @@ import 'dart:io' show Process;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
import 'package:flutter/gestures.dart'
show kLongPressTimeout, kTouchSlop, PointerDownEvent, PointerUpEvent;
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
@ -47,6 +49,16 @@ class _ReaderScreenState extends State<ReaderScreen> {
bool _showBrokenText = false;
final _checkedSubscriptions = <String>{};
// The finger behind a possible tap on the page, tracked outside the
// gesture arena because taps over selectable text lose it.
Offset? _tapDownPosition;
Duration _tapDownTime = Duration.zero;
/// Whether the tap in flight acted on the page: opened a link, submitted
/// a form or pressed a control in one of the chrome's own pages. Such a
/// tap leaves the bar where it was.
bool _tapActed = false;
/// Touch devices navigate with gestures; the others are pointer hosts
/// whose readers reach the chrome with the mouse and the keyboard.
static bool get _isTouch => switch (defaultTargetPlatform) {
@ -85,73 +97,135 @@ class _ReaderScreenState extends State<ReaderScreen> {
}
}
void _onPageTapDown(PointerDownEvent event) {
_tapDownPosition = event.position;
_tapDownTime = event.timeStamp;
_tapActed = false;
}
/// A short tap on the page shows a hidden address bar and hides a shown
/// one. A page that fits the viewport never scrolls, so a hidden bar has
/// nothing else to bring it back.
void _onPageTapUp(PointerUpEvent event) {
if (_tapDownPosition == null || !_isTouch) return;
final travel = (event.position - _tapDownPosition!).distance;
_tapDownPosition = null;
// A finger that travelled was scrolling or swiping; one that held that
// long began a selection. Both leave the bar where it was.
if (travel > kTouchSlop) return;
if (event.timeStamp - _tapDownTime >= kLongPressTimeout) return;
// A tap that acts on the page — a link, a form — runs as the gesture
// arena settles after this event, so the decision waits for the frame
// that carries it.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// A tap that opened something, or that put the focus in a field of
// the page, is not a tap on the reading surface.
if (_tapActed) return;
if (_focusInPageField()) return;
setState(() => _chromeVisible = !_chromeVisible);
});
}
/// Whether a page field, such as a search box, holds the focus: a tap
/// that gave it the focus is a tap into a form, not on the page.
bool _focusInPageField() => FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<TextField>() !=
null;
/// Runs [action] for a control in one of the chrome's own error pages;
/// pressing it acts on the page, so the bar keeps its place.
VoidCallback _press(VoidCallback action) => () {
_tapActed = true;
action();
};
@override
Widget build(BuildContext context) {
final deps = AppScope.of(context).deps;
final reader = Scaffold(
body: Column(
children: [
AnimatedSlide(
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: AnimatedSize(
// A phone draws under the status bar, so the chrome starts below it;
// the reader content above and beside it already reaches the edges.
body: SafeArea(
top: true,
bottom: false,
left: false,
right: false,
child: Column(
children: [
AnimatedSlide(
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _chromeVisible
? AddressBar(focusNode: _addressFocus)
: const SizedBox(width: double.infinity),
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _chromeVisible
? AddressBar(focusNode: _addressFocus)
: const SizedBox(width: double.infinity),
),
),
),
Expanded(
child: SignalBuilder(builder: (context) {
final browser = deps.browser;
final page = browser.currentPage.value;
// The swipe peek uses the current page's palette when there is
// one, so the neighbor slides in on matching paper.
final peekBackground = page == null
? Theme.of(context).scaffoldBackgroundColor
: paletteFor(
pageVariant: page.variant,
readerVariant: deps.settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
).background;
Expanded(
child: SignalBuilder(builder: (context) {
final browser = deps.browser;
final page = browser.currentPage.value;
// The swipe peek uses the current page's palette when there is
// one, so the neighbor slides in on matching paper.
final peekBackground = page == null
? Theme.of(context).scaffoldBackgroundColor
: paletteFor(
pageVariant: page.variant,
readerVariant: deps.settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
).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;
// 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,
backView: _neighborView(browser.backPage.value),
forwardView: _neighborView(browser.forwardPage.value),
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(
children: [
Positioned.fill(child: _body(context, page)),
if (browser.loading.value)
const Positioned(
top: 8,
left: 0,
right: 0,
child: LinearProgressIndicator(minHeight: 2),
),
],
),
);
}),
),
],
return SwipeNavigator(
canGoBack: browser.canGoBack,
canGoForward: browser.canGoForward,
backView: _neighborView(browser.backPage.value),
forwardView: _neighborView(browser.forwardPage.value),
backTitle: browser.backPage.value?.title,
forwardTitle: browser.forwardPage.value?.title,
background: peekBackground,
contentWidth: page == null ? null : contentWidth,
// A touch host has no margin beside the column, so there
// the page itself carries the back and forward swipes.
swipesAnywhere: _isTouch,
onBack: browser.back,
onForward: browser.forward,
// Opaque, so a tap on a blank stretch of the page counts
// too; the widgets inside keep their own taps.
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPageTapDown,
onPointerUp: _onPageTapUp,
child: Stack(
children: [
Positioned.fill(child: _body(context, page)),
if (browser.loading.value)
const Positioned(
top: 8,
left: 0,
right: 0,
child: LinearProgressIndicator(minHeight: 2),
),
],
),
),
);
}),
),
],
),
),
);
@ -252,9 +326,14 @@ class _ReaderScreenState extends State<ReaderScreen> {
fontFamily: font,
fontScale: fontScale,
visitedUrls: visited,
onLinkTap: (url) => deps.browser.open(url),
onFormSubmit: (form, values) =>
deps.browser.submitForm(form.action, form.method, values),
onLinkTap: (url) {
_tapActed = true;
deps.browser.open(url);
},
onFormSubmit: (form, values) {
_tapActed = true;
deps.browser.submitForm(form.action, form.method, values);
},
onFormChange: (form, values) =>
deps.browser.narrowWhileTyping(form.action, values),
fetchImageBytes: deps.browser.fetchImageBytes,
@ -322,7 +401,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
],
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
@ -355,7 +434,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
const SizedBox(height: 16),
if (held.isEmpty)
FilledButton(
onPressed: () => deps.browser.open(MewsUrls.certificates),
onPressed: _press(() => deps.browser.open(MewsUrls.certificates)),
child: Text(strings.t('certificates-add')),
)
else
@ -363,13 +442,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FilledButton.tonal(
onPressed: () => deps.browser.chooseCertificate(certificate.id),
onPressed: _press(() => deps.browser.chooseCertificate(certificate.id)),
child: Text(certificate.name),
),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
@ -395,12 +474,12 @@ class _ReaderScreenState extends State<ReaderScreen> {
Text(strings.t('error-certificate-changed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.trustPresentedCertificate(),
onPressed: _press(() => deps.browser.trustPresentedCertificate()),
child: Text(strings.t('error-certificate-trust')),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
@ -457,13 +536,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
Text(strings.t('error-load-failed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.reload(),
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
if (canOpenOutside) ...[
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _openOutside(failed),
onPressed: _press(() => _openOutside(failed)),
child: Text(strings.t('error-open-in-browser')),
),
],
@ -485,7 +564,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
Text(strings.t('error-broken-page'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => setState(() => _showBrokenText = true),
onPressed: _press(() => setState(() => _showBrokenText = true)),
child: Text(strings.t('error-broken-page-show-text')),
),
],

View file

@ -1,5 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart'
show kLongPressTimeout, kTouchSlop, PointerDeviceKind;
import 'package:flutter/material.dart';
/// Wraps the current page and turns the back/forward swipes into visible page
@ -22,6 +24,7 @@ class SwipeNavigator extends StatefulWidget {
this.backTitle,
this.forwardTitle,
this.contentWidth,
this.swipesAnywhere = false,
super.key,
});
@ -41,6 +44,15 @@ class SwipeNavigator extends StatefulWidget {
/// the base edge zone.
final double? contentWidth;
/// Whether the page itself answers horizontal drags, not only the strips.
/// A touch host has no margin beside the column to swipe in, so there the
/// whole surface navigates: the finger is tracked outside the gesture
/// arena, because a selectable page's own recognizer wins every
/// horizontal drag over its text there. A swipe crosses the slop sideways
/// before the long press that starts a selection completes, so the two
/// never compete for the same finger.
final bool swipesAnywhere;
final Color background;
final VoidCallback onBack;
final VoidCallback onForward;
@ -71,6 +83,14 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
bool _dragging = false;
double _width = 0;
// The raw touch swipe's finger, tracked outside the gesture arena.
int? _trackedPointer;
Offset _downPosition = Offset.zero;
Duration _downTime = Duration.zero;
Offset _lastPosition = Offset.zero;
Duration _lastMoveTime = Duration.zero;
double _swipeVelocity = 0;
@override
void dispose() {
_settle.dispose();
@ -99,12 +119,15 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
setState(() => _drag += details.delta.dx);
}
void _onDragEnd(DragEndDetails details) {
void _onDragEnd(DragEndDetails details) =>
_commitSwipe(details.primaryVelocity ?? 0);
void _commitSwipe(double velocity) {
if (!_dragging) return;
final dx = _displayed;
final velocity = details.primaryVelocity ?? 0;
final goBack = widget.canGoBack && (dx > _width * 0.3 || velocity > 500);
final goForward = widget.canGoForward && (dx < -_width * 0.3 || velocity < -500);
final goForward =
widget.canGoForward && (dx < -_width * 0.3 || velocity < -500);
_animateTo(
goBack
@ -119,6 +142,71 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
);
}
// Raw touch tracking ------------------------------------------------------
void _onPagePointerDown(PointerDownEvent event) {
if (_trackedPointer != null || _dragging || _settledAnimating) return;
// A mouse drives a pointer host's strips, not this listener.
if (event.kind == PointerDeviceKind.mouse) return;
_trackedPointer = event.pointer;
_downPosition = event.position;
_downTime = event.timeStamp;
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
_swipeVelocity = 0;
}
void _onPagePointerMove(PointerMoveEvent event) {
if (_trackedPointer != event.pointer) return;
final total = event.position - _downPosition;
if (!_dragging) {
if (total.distance <= kTouchSlop) {
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
return;
}
// The gate between a swipe and a selection: a finger that crosses the
// slop sideways, before the long press that starts a selection
// completes, is swiping. One that held still that long, or traveled
// along the page first, belongs to text selection or to scrolling.
final stillInSwipeWindow =
event.timeStamp - _downTime < kLongPressTimeout;
final sideways = total.dx.abs() > total.dy.abs();
if (!stillInSwipeWindow || !sideways) {
_trackedPointer = null;
return;
}
setState(() {
_dragging = true;
_drag = total.dx;
});
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
return;
}
setState(() => _drag += event.position.dx - _lastPosition.dx);
final elapsed = event.timeStamp - _lastMoveTime;
if (elapsed > Duration.zero) {
_swipeVelocity =
(event.position.dx - _lastPosition.dx) * 1e6 / elapsed.inMicroseconds;
}
_lastPosition = event.position;
_lastMoveTime = event.timeStamp;
}
void _finishPagePointer(PointerEvent event, {required bool commit}) {
if (_trackedPointer != event.pointer) return;
_trackedPointer = null;
if (!_dragging) return;
if (commit) {
_commitSwipe(_swipeVelocity);
} else {
_animateTo(0);
}
}
/// The page offset shown: full travel when a page waits in that direction,
/// a rubber band when none does, capped near the screen width.
double get _displayed {
@ -182,13 +270,36 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
if (peek != null) Positioned.fill(child: peek),
Transform.translate(
offset: Offset(dx, 0),
child: widget.child,
// Opaque, so a drag over a blank stretch of an error page
// navigates too; the widgets inside still take their own taps,
// scrolls and long presses first.
// The raw listener owns the touch swipe: a selectable page's
// own recognizer wins every horizontal drag over its text in
// the arena, so the finger is tracked outside it. Opaque, so
// a drag over a blank stretch of an error page navigates too;
// the widgets inside keep their own taps, scrolls and long
// presses.
child: widget.swipesAnywhere
? Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPagePointerDown,
onPointerMove: _onPagePointerMove,
onPointerUp: (event) => _finishPagePointer(event, commit: true),
onPointerCancel: (event) =>
_finishPagePointer(event, commit: false),
child: widget.child,
)
: widget.child,
),
// 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)),
// and mid-page selection still pass through everywhere. A touch
// host navigates with the raw listener instead; strips there
// would feed the same drag twice.
if (!widget.swipesAnywhere) ...[
_edgeStrip(fromLeft: true, width: _stripWidth(_width)),
_edgeStrip(fromLeft: false, width: _stripWidth(_width)),
],
],
);
},